diff --git a/devicetypes/piratemedia/smartthings/virtual-thermostat-device.src/virtual-thermostat-device.groovy b/devicetypes/piratemedia/smartthings/virtual-thermostat-device.src/virtual-thermostat-device.groovy index afdf1b8..c475437 100644 --- a/devicetypes/piratemedia/smartthings/virtual-thermostat-device.src/virtual-thermostat-device.groovy +++ b/devicetypes/piratemedia/smartthings/virtual-thermostat-device.src/virtual-thermostat-device.groovy @@ -1,34 +1,48 @@ metadata { definition (name: "Virtual Thermostat Device", namespace: "piratemedia/smartthings", - author: "Eliot S.", + author: "Eliot S. + Steffen N.", mnmn: "SmartThings", vid: "generic-thermostat-1", executeCommandsLocally: true, ocfDeviceType: "oic.d.thermostat") { capability "Temperature Measurement" + capability "Relative Humidity Measurement" capability "Thermostat" capability "Thermostat Mode" capability "Thermostat Heating Setpoint" + capability "Thermostat Cooling Setpoint" capability "Thermostat Operating State" capability "Configuration" capability "Refresh" command "refresh" command "poll" - + command "offbtn" + command "coolbtn" command "heatbtn" - command "levelUpDown" + command "autobtn" command "levelUp" command "levelDown" + command "smartCoolDown" + command "smartHeatUp" command "heatingSetpointUp" command "heatingSetpointDown" - command "changeMode" + command "coolingSetpointUp" + command "coolingSetpointDown" command "setVirtualTemperature", ["number"] - command "setHeatingStatus", ["string"] - + command "setVirtualHumidity", ["number"] + command "setHeatCoolDelta", ["number"] + command "setHeatDiff", ["number"] + command "setCoolDiff", ["number"] + attribute "temperatureUnit", "string" + attribute "heatCoolDelta", "number" + attribute "heatDiff", "number" + attribute "coolDiff", "number" + attribute "adjustedHeatingPoint", "number" + attribute "adjustedCoolingPoint", "number" } simulator { @@ -41,82 +55,155 @@ metadata { attributeState("default", label:'${currentValue}°', unit: unitString()) } - tileAttribute("device.thermostatSetpoint", key: "VALUE_CONTROL") { - attributeState("default", action: "levelUpDown") - attributeState("VALUE_UP", action: "levelUp") - attributeState("VALUE_DOWN", action: "levelDown") + tileAttribute("device.thermostatOperatingState", key: "VALUE_CONTROL") { + attributeState("VALUE_UP", label: '', action: "levelUp") + attributeState("VALUE_DOWN", label: '', action: "levelDown") } tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") { + // valid values are thermostatOperatingState — ["heating", "idle", "pending cool", "vent economizer", "cooling", "pending heat", "fan only"] + // https://graph.api.smartthings.com/ide/doc/capabilities + + attributeState("off", backgroundColor: "#cccccc") attributeState("idle", backgroundColor: "#44B621") - attributeState("heating", backgroundColor: "#FFA81E") - attributeState("off", backgroundColor: "#ddcccc") - attributeState("pending heat", backgroundColor: "#e60000") + attributeState("heating", backgroundColor: "#e86d13") + attributeState("cooling", backgroundColor: "#00a0dc") + attributeState("pending heat", backgroundColor: "#ffd19c") + attributeState("pending cool", backgroundColor: "#85b3d6") } tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") { - attributeState("off", label:'Off') + attributeState("cool", label:'Cool') attributeState("heat", label:'Heat') + attributeState("auto", label:'Auto') + attributeState("off", label:'Off') } - tileAttribute("device.thermostatSetpoint", key: "HEATING_SETPOINT") { + tileAttribute("device.adjustedHeatingPoint", key: "HEATING_SETPOINT") { + attributeState("default", label:'${currentValue}') + } + + tileAttribute("device.adjustedCoolingPoint", key: "COOLING_SETPOINT") { attributeState("default", label:'${currentValue}') } + + tileAttribute("device.humidity", key: "SECONDARY_CONTROL") { + attributeState("humidity", label:'${currentValue}%', unit:"%", defaultState: true) + } } - valueTile("temp2", "device.temperature", width: 2, height: 2, decoration: "flat") { + valueTile("tempmain", "device.temperature", width: 2, height: 2, decoration: "flat") { state("default", label:'${currentValue}°', icon:"https://raw.githubusercontent.com/eliotstocker/SmartThings-VirtualThermostat-WithDTH/master/device.png", backgroundColors: getTempColors(), canChangeIcon: true) } - standardTile("thermostatMode", "device.thermostatMode", width:2, height:2, decoration: "flat") { - state("off", action:"changeMode", nextState: "updating", icon: "https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/off_icon.png") - state("heat", action:"changeMode", nextState: "updating", icon: "https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_icon.png") - state("Updating", label:"", icon: "https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/cmd_working.png") + standardTile("thermostatMode", "device.thermostatMode", width:6, height:2, decoration: "flat") { + state("off", label: '${name}') + state("heat", label: '${name}') + state("cool", label: '${name}') + state("auto", label: '${name}') } - standardTile("offBtn", "device.off", width:1, height:1, decoration: "flat") { - state("Off", action: "offbtn", icon: "https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/off_icon.png") + standardTile("offBtn", "device.thermostatMode", width:2, height:4, decoration: "flat") { + state("", action: "offbtn", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/unit_on.png", default: true) + state("off", action: "offbtn", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/unit_off.png") } - standardTile("heatBtn", "device.canHeat", width:1, height:1, decoration: "flat") { - state("Heat", action: "heatbtn", icon: "https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_icon.png") - state "false", label: '' + standardTile("heatBtn", "device.thermostatMode", width:2, height:2, decoration: "flat") { + state("", action: "heatbtn", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/heat_off.png", default: true) + state("heat", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/heat_on.png") } - - standardTile("refresh", "device.refresh", width:2, height:2, decoration: "flat") { + + standardTile("coolBtn", "device.thermostatMode", width:2, height:2, decoration: "flat") { + state("", action: "coolbtn", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/cool_off.png", default: true) + state("cool", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/cool_on.png") + } + + standardTile("autoBtn", "device.thermostatMode", width:2, height:2, decoration: "flat") { + state("", action: "autobtn", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/auto_off.png", default: true) + state("auto", icon: "https://raw.githubusercontent.com/steffennissen/SmartThings-VirtualThermostat-WithDTH/master/images/auto_on.png") + } + + standardTile("refresh", "device.refresh", width:1, height:1, decoration: "flat") { state "Refresh", action:"refresh.refresh", icon:"st.secondary.refresh" } - valueTile("heatingSetpoint", "device.thermostatSetpoint", width: 1, height: 1) { - state("heatingSetpoint", label:'${currentValue}', unit: unitString(), foregroundColor: "#FFFFFF", - backgroundColors: [ [value: 0, color: "#FFFFFF"], [value: 7, color: "#FF3300"], [value: 15, color: "#FF3300"] ]) + valueTile("heatingSetpoint", "device.heatingSetpoint", width: 2, height: 2) { + state("heatingSetpoint", action: "heatbtn", label:'${currentValue}', unit: unitString(), foregroundColor: "#FFFFFF", backgroundColor: "#e86d13") state("disabled", label: '', foregroundColor: "#FFFFFF", backgroundColor: "#FFFFFF") } - standardTile("heatingSetpointUp", "device.thermostatSetpoint", width: 1, height: 1, canChangeIcon: true, decoration: "flat") { - state "default", label: '', action:"heatingSetpointUp", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_up.png" - state "", label: '' + standardTile("heatingSetpointUp", "device.thermostatMode", width: 2, height: 1, canChangeIcon: true, decoration: "flat") { + state "auto", label: '', action:"heatingSetpointUp", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_up.png" + state "heat", label: '', action:"heatingSetpointUp", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_up.png" + state "cool", label: '' + state "off", label: '' } - standardTile("heatingSetpointDown", "device.thermostatSetpoint", width: 1, height: 1, canChangeIcon: true, decoration: "flat") { - state "default", label:'', action:"heatingSetpointDown", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_down.png" - state "", label: '' + standardTile("heatingSetpointDown", "device.thermostatMode", width: 2, height: 1, canChangeIcon: true, decoration: "flat") { + state "auto", label:'', action:"heatingSetpointDown", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_down.png" + state "heat", label:'', action:"heatingSetpointDown", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_down.png" + state "cool", label: '' + state "off", label: '' } - controlTile("heatSliderControl", "device.thermostatSetpoint", "slider", height: 1, width: 3, range: getRange(), inactiveLabel: false) { - state "default", action:"setHeatingSetpoint", backgroundColor:"#FF3300" + controlTile("heatSliderControl", "device.heatingSetpoint", "slider", height: 2, width: 2, range: getRange()) { + state "default", action:"setHeatingSetpoint", backgroundColor:"#e86d13" + } + + valueTile("coolingSetpoint", "device.coolingSetpoint", width: 2, height: 2) { + state("coolingSetpoint", action: "coolbtn", label:'${currentValue}', unit: unitString(), foregroundColor: "#FFFFFF", backgroundColor: "#00a0dc") + state("disabled", label: '', foregroundColor: "#FFFFFF", backgroundColor: "#FFFFFF") + } + + standardTile("coolingSetpointUp", "device.thermostatMode", width: 2, height: 1, canChangeIcon: true, decoration: "flat") { + state "default", label: '', action:"coolingSetpointUp", icon:"https://raw.githubusercontent.com/racarmichael/SmartThings-VirtualThermostat-WithDTH/master/images/cool_arrow_up.png" + state "heat", label: '' + state "off", label: '' + } + + standardTile("coolingSetpointDown", "device.thermostatMode", width: 2, height: 1, canChangeIcon: true, decoration: "flat") { + state "default", label:'', action:"coolingSetpointDown", icon:"https://raw.githubusercontent.com/racarmichael/SmartThings-VirtualThermostat-WithDTH/master/images/cool_arrow_down.png" + state "heat", label: '' + state "off", label: '' + } + + controlTile("coolSliderControl", "device.coolingSetpoint", "slider", height: 1, width: 4, range: getRange(), inactiveLabel: true) { + state "default", action:"setCoolingSetpoint", backgroundColor:"#0022ff" state "", label: '' } + + standardTile("smartCool", "device.thermostatMode", width:3, height:2, decoration: "flat") { + state "default", label:'Smart Cool Down', action:"smartCoolDown", icon:"https://raw.githubusercontent.com/racarmichael/SmartThings-VirtualThermostat-WithDTH/master/images/cool_arrow_down.png" + } + + standardTile("smartHeat", "device.thermostatMode", width:3, height:2, decoration: "flat") { + state "default", label:'Smart Heat Up', action:"smartHeatUp", icon:"https://raw.githubusercontent.com/tonesto7/nest-manager/master/Images/Devices/heat_arrow_up.png" + } - main("temp2") + main("tempmain") - details( ["temperature", "thermostatMode", - "heatingSetpointDown", "heatingSetpoint", "heatingSetpointUp", - "heatSliderControl", "offBtn", "heatBtn", "refresh"] ) + details( ["temperature", + "coolingSetpointUp", "heatingSetpointUp", + "offBtn", + "coolingSetpoint", + "heatingSetpoint", + //"heatSliderControl", + "coolingSetpointDown", "heatingSetpointDown", + //"thermostatMode", + "smartCool", "smartHeat", + "coolBtn","heatBtn", + "autoBtn", + //"coolSliderControl" + //"refresh", + ] ) } } +def thermostatModes() { + return ['cool', 'heat', 'auto', 'off'] +} + def shouldReportInCentigrade() { try { def ts = getTemperatureScale(); @@ -140,21 +227,23 @@ def configure() { private initialize() { log.trace "Executing 'initialize'" - setHeatingSetpoint(defaultTemp()) - setVirtualTemperature(defaultTemp()) - setHeatingStatus("off") + setHeatCoolDelta(0) + setHeatDiff(0) + setCoolDiff(0) + sendCoolingSetpoint(defaultTemp()+2.0) + sendHeatingSetpoint(defaultTemp()-2.0) + setThermostatOperatingState("off") setThermostatMode("off") - sendEvent(name:"supportedThermostatModes", value: ['heat', 'off'], displayed: false) - sendEvent(name:"supportedThermostatFanModes", values: [], displayed: false) - + setVirtualTemperature(defaultTemp()) + setVirtualHumidity(50) + sendEvent(name:"supportedThermostatModes", value: thermostatModes(), displayed: false) + state.tempScale = "C" } def getTempColors() { - def colorMap - //getTemperatureScale() == "C" wantMetric() if(shouldReportInCentigrade()) { - colorMap = [ + return [ // Celsius Color Range [value: 0, color: "#153591"], [value: 7, color: "#1e9cbb"], @@ -165,7 +254,7 @@ def getTempColors() { [value: 36, color: "#bc2323"] ] } else { - colorMap = [ + return [ // Fahrenheit Color Range [value: 40, color: "#153591"], [value: 44, color: "#1e9cbb"], @@ -179,47 +268,122 @@ def getTempColors() { } def unitString() { return shouldReportInCentigrade() ? "C": "F" } -def defaultTemp() { return shouldReportInCentigrade() ? 20 : 70 } -def lowRange() { return shouldReportInCentigrade() ? 9 : 45 } -def highRange() { return shouldReportInCentigrade() ? 45 : 113 } +def defaultTemp() { return shouldReportInCentigrade() ? 23 : 73 } +def lowRange() { return shouldReportInCentigrade() ? 7.0 : 45.0 } +def highRange() { return shouldReportInCentigrade() ? 45.0 : 113.0 } def getRange() { return "${lowRange()}..${highRange()}" } def getTemperature() { return device.currentValue("temperature") } +def roundTemp(temp) { + return Math.round(temp * 100) / 100; +} + +def sendCoolingSetpoint(temp) { + def csp = device.currentValue("coolingSetpoint") + if(temp != csp) { + temp = roundTemp(temp); + log.debug "sendCoolingSetpoint from " + csp + " to " + temp + sendEvent(name:"coolingSetpoint", value: temp, unit: unitString()) + if(device.currentValue("thermostatOperatingState") == "cooling") { + setAdjustedCoolingPoint(temp) + } else { + setAdjustedCoolingPoint(temp + device.currentValue("coolDiff")) + } + } +} + +def sendHeatingSetpoint(temp) { + def hsp = device.currentValue("heatingSetpoint") + if(temp != hsp) { + temp = roundTemp(temp); + log.debug "sendHeatingSetpoint from " + hsp + " to " + temp + sendEvent(name:"heatingSetpoint", value: temp, unit: unitString()) + if(device.currentValue("thermostatOperatingState") == "heating") { + setAdjustedHeatingPoint(temp) + } else { + setAdjustedHeatingPoint(temp - device.currentValue("heatDiff")) + } + } +} + +def inRange(val, low, high) { + if(val < low) + return low + else if(val > high) + return high + return val +} + def setHeatingSetpoint(temp) { - def ctsp = device.currentValue("thermostatSetpoint"); - def chsp = device.currentValue("heatingSetpoint"); + def hsp = device.currentValue("heatingSetpoint"); + log.debug "setHeatingSetpoint from " + hsp + " to " + temp + " within range " + lowRange() + " to " + highRange() + temp = inRange(temp, lowRange(), highRange()) + + if(hsp != temp) { + def targetCool = temp + device.currentValue('heatCoolDelta') + if(device.currentValue("coolingSetpoint") < targetCool) { + sendCoolingSetpoint(targetCool) + } - if(ctsp != temp || chsp != temp) { - sendEvent(name:"thermostatSetpoint", value: temp, unit: unitString(), displayed: false) - sendEvent(name:"heatingSetpoint", value: temp, unit: unitString()) + sendHeatingSetpoint(temp) } } +def setCoolingSetpoint(temp) { + def csp = device.currentValue("coolingSetpoint"); + log.debug "setCoolingSetpoint: from " + csp + " to " + temp + " within range " + lowRange() + " to " + highRange() + temp = inRange(temp, lowRange(), highRange()) + + if(csp != temp) { + def targetHeat = temp - device.currentValue('heatCoolDelta') + if(device.currentValue("heatingSetpoint") > targetHeat) { + sendHeatingSetpoint(targetHeat) + } + + sendCoolingSetpoint(temp) + } +} + def heatingSetpointUp() { - def hsp = device.currentValue("thermostatSetpoint") - if(hsp + 1.0 > highRange()) return; - setHeatingSetpoint(hsp + 1.0) + setHeatingSetpoint(device.currentValue("heatingSetpoint") + 0.1) } def heatingSetpointDown() { - def hsp = device.currentValue("thermostatSetpoint") - if(hsp - 1.0 < lowRange()) return; - setHeatingSetpoint(hsp - 1.0) + setHeatingSetpoint(device.currentValue("heatingSetpoint") - 0.1) +} + +def coolingSetpointUp() { + setCoolingSetpoint(device.currentValue("coolingSetpoint") + 0.1) +} + +def coolingSetpointDown() { + setCoolingSetpoint(device.currentValue("coolingSetpoint") - 0.1) +} + +def levelChange(diff) { + def mode = device.currentValue("thermostatMode") + switch (mode) { + case "heat": + setHeatingSetpoint(device.currentValue("heatingSetpoint") + diff) + break + case "cool": + setCoolingSetpoint(device.currentValue("coolingSetpoint") + diff) + break + default: + setHeatingSetpoint(device.currentValue("heatingSetpoint") + diff) + setCoolingSetpoint(device.currentValue("coolingSetpoint") + diff) + } } def levelUp() { - def hsp = device.currentValue("thermostatSetpoint") - if(hsp + 1.0 > highRange()) return; - setHeatingSetpoint(hsp + 1.0) + levelChange(0.5) } def levelDown() { - def hsp = device.currentValue("thermostatSetpoint") - if(hsp - 1.0 < lowRange()) return; - setHeatingSetpoint(hsp - 1.0) + levelChange(-0.5) } def parse(data) { @@ -228,8 +392,7 @@ def parse(data) { def refresh() { log.trace "Executing refresh" - sendEvent(name: "supportedThermostatModes", value: ['heat', 'off'], displayed: false) - sendEvent(name: "supportedThermostatFanModes", values: [], displayed: false) + configure() } def getThermostatMode() { @@ -240,46 +403,195 @@ def getOperatingState() { return device.currentValue("thermostatOperatingState") } -def getThermostatSetpoint() { - return device.currentValue("thermostatSetpoint") -} - def getHeatingSetpoint() { return device.currentValue("heatingSetpoint") } +def getCoolingSetpoint() { + return device.currentValue("coolingSetpoint") +} + +def getHeatDiff() { + return device.currentValue("heatDiff") +} + +def getCoolDiff() { + return device.currentValue("coolDiff") +} + def poll() { } def offbtn() { - setThermostatMode("off") + log.debug "offbtn, lastmode=" + state.lastMode + if(device.currentValue("thermostatMode") != "off") { + state.lastMode = device.currentValue("thermostatMode") + setThermostatMode("off") + } else { + if(state.lastMode) { + setThermostatMode(state.lastMode) + } else { + setThermostatMode("auto") + } + } +} + +def coolbtn() { + setThermostatMode("cool") } def heatbtn() { setThermostatMode("heat") } +def autobtn() { + log.debug "autobtn" + setThermostatMode("auto") +} + def setThermostatMode(mode) { + log.trace "setting thermostat mode $mode" if(device.currentValue("thermostatMode") != mode) { sendEvent(name: "thermostatMode", value: mode) } } -def levelUpDown() { +def setVirtualTemperature(temp) { + sendEvent(name:"temperature", value: temp, unit: unitString(), displayed: true) } -def changeMode() { - def val = device.currentValue("thermostatMode") == "off" ? "heat" : "off" - setThermostatMode(val) - return val +def setVirtualHumidity(humidity) { + sendEvent(name:"humidity", value: humidity, unit: unitString(), displayed: true) } -def setVirtualTemperature(temp) { - sendEvent(name:"temperature", value: temp, unit: unitString(), displayed: true) +def setHeatCoolDelta(delta) { + sendEvent(name:"heatCoolDelta", value: delta, unit: unitString(), displayed: true) +} + +def setHeatDiff(diff) { + sendEvent(name:"heatDiff", value: diff, unit: unitString(), displayed: true) +} + +def setCoolDiff(diff) { + sendEvent(name:"coolDiff", value: diff, unit: unitString(), displayed: true) +} + +def setAdjustedHeatingPoint(point) { + if(device.currentValue("adjustedHeatingPoint") != point) { + sendEvent(name:"adjustedHeatingPoint", value: point, unit: unitString(), displayed: true) + } +} + +def setAdjustedCoolingPoint(point) { + if(device.currentValue("adjustedCoolingPoint") != point) { + sendEvent(name:"adjustedCoolingPoint", value: point, unit: unitString(), displayed: true) + } } -def setHeatingStatus(string) { - if(device.currentValue("thermostatOperatingState") != string) { - sendEvent(name:"thermostatOperatingState", value: string) +def setThermostatOperatingState(operatingState) { + if(device.currentValue("thermostatOperatingState") != operatingState) { + sendEvent(name:"thermostatOperatingState", value: operatingState) + if(operatingState == "heating") { + setAdjustedHeatingPoint(device.currentValue("heatingSetpoint")) + } else { + setAdjustedHeatingPoint(device.currentValue("heatingSetpoint") - device.currentValue("heatDiff")) + } + if(operatingState == "cooling") { + setAdjustedCoolingPoint(device.currentValue("coolingSetpoint")) + } else { + setAdjustedCoolingPoint(device.currentValue("coolingSetpoint") + device.currentValue("coolDiff")) + } } +} + +//The idea behind the smart cool and heat is to change the setpoint, so that if it's not cooling/heating now it will start so immediately +// and likewise if it's already cooling/heating it will stop. The idea is to change the setpoint enough for this change to happen right now +// and that it will stay that way at least for a little while. Without moving the setpoint too much. +// Good example of use is of the thermostat is set to cool and it starts cooling, but you don't think it hot enough that it should start right now, +// so you would click smartHeatUp. Alternatively if your thermostat is set to cool, but it's not cooling right now and you would want it to, you would +// press smartCoolDown +def smartCoolDown(){ + log.debug "smartCoolDown => thermostatMode: ${getThermostatMode()}, operatingState: ${getOperatingState()}" + def diff = getCoolDiff().max(0.3) // if diff is too small, this is not doing much + + if(getOperatingState() == "heating") { + def setPointToStopHeating = getTemperature() - 0.1; + def targetFromSetPoint = getHeatingSetpoint() - (diff*2) + def target = setPointToStopCooling.min(targetFromSetPoint) + def targetDiff = target - getHeatingSetpoint(); + log.debug "smartCoolDown while heating => temp: ${getTemperature()}, heatSetPoint: ${getHeatingSetpoint()}, diff: ${diff}, targetFromSetPoint: ${targetFromSetPoint}, setPointToStopHeating: ${setPointToStopHeating}, target: ${target}, targetDiff: ${targetDiff}" + levelChange(targetDiff) + return; + } + + if(getThermostatMode() == "heat") { + log.debug "smartCoolDown while in heat mode, but not actually heating, simple move the setpoint down" + levelChange(-diff) + return + } + + // Change the thermostat mode, so it's either cool or auto + if(getThermostatMode() == "off") { + if(state.lastMode && state.lastMode != "heat") { + setThermostatMode(state.lastMode) + } else { + setThermostatMode("cool") + } + } + + if(getOperatingState() == "cooling" || getTemperature() > getCoolingSetpoint()){ + log.debug "smartCoolDown already in a state where it should be cooling, lower the setpoint by ${diff}" + levelChange(-diff) + return + } + + def setPointToStartCooling = getTemperature() - diff - 0.1; + def targetFromSetPoint = getCoolingSetpoint() - (diff*2) + def target = targetFromSetPoint.min(setPointToStartCooling) + def targetDiff = target - getCoolingSetpoint() + log.debug "smartCoolDown => temp: ${getTemperature()}, coolSetPoint: ${getCoolingSetpoint()}, diff: ${diff}, targetFromSetPoint: ${targetFromSetPoint}, setPointToStartCooling: ${setPointToStartCooling}, target: ${target}, targetDiff: ${targetDiff}" + levelChange(targetDiff) +} + +def smartHeatUp(){ + log.debug "smartHeatUp => thermostatMode: ${getThermostatMode()}, operatingState: ${getOperatingState()}" + def diff = getHeatDiff().max(0.3) // if diff is too small, this is not doing much + + if(getOperatingState() == "cooling") { + def setPointToStopCooling = getTemperature() + 0.1; + def targetFromSetPoint = getCoolingSetpoint() + (diff*2) + def target = setPointToStopCooling.max(targetFromSetPoint) + def targetDiff = target - getCoolingSetpoint(); + log.debug "smartHeatUp while cooling => temp: ${getTemperature()}, coolSetPoint: ${getCoolingSetpoint()}, diff: ${diff}, targetFromSetPoint: ${targetFromSetPoint}, setPointToStopCooling: ${setPointToStopCooling}, target: ${target}, targetDiff: ${targetDiff}" + levelChange(targetDiff) + return; + } + + if(getThermostatMode() == "cool") { + log.debug "smartHeatUp while in cool mode, but not actually cooling, simple move the setpoint up" + levelChange(diff) + return + } + + // Change the thermostat mode, so it's either heat or auto + if(getThermostatMode() == "off") { + if(state.lastMode && state.lastMode != "cool") { + setThermostatMode(state.lastMode) + } else { + setThermostatMode("heat") + } + } + + if(getOperatingState() == "heating" || getTemperature() < getHeatingSetpoint()){ + log.debug "smartHeatUp already in a state where it should be heating, increase the setpoint by ${diff}" + levelChange(diff) + return + } + + def setPointToStartHeating = getTemperature() + diff + 0.1; + def targetFromSetPoint = getHeatingSetpoint() + (diff*2) + def target = targetFromSetPoint.max(setPointToStartHeating) + def targetDiff = target - getHeatingSetpoint(); + log.debug "smartHeatUp => temp: ${getTemperature()}, heatSetPoint: ${getHeatingSetpoint()}, diff: ${diff}, targetFromSetPoint: ${targetFromSetPoint}, setPointToStartHeating: ${setPointToStartHeating}, target: ${target}, targetDiff: ${targetDiff}" + levelChange(targetDiff) } \ No newline at end of file diff --git a/images/auto_off.png b/images/auto_off.png new file mode 100644 index 0000000..4583819 Binary files /dev/null and b/images/auto_off.png differ diff --git a/images/auto_on.png b/images/auto_on.png new file mode 100644 index 0000000..748d9aa Binary files /dev/null and b/images/auto_on.png differ diff --git a/images/cool_off.png b/images/cool_off.png new file mode 100644 index 0000000..4f3deb5 Binary files /dev/null and b/images/cool_off.png differ diff --git a/images/cool_on.png b/images/cool_on.png new file mode 100644 index 0000000..ff29035 Binary files /dev/null and b/images/cool_on.png differ diff --git a/images/heat_off.png b/images/heat_off.png new file mode 100644 index 0000000..004a67f Binary files /dev/null and b/images/heat_off.png differ diff --git a/images/heat_on.png b/images/heat_on.png new file mode 100644 index 0000000..104c28d Binary files /dev/null and b/images/heat_on.png differ diff --git a/images/unit_off.png b/images/unit_off.png new file mode 100644 index 0000000..a2fd47d Binary files /dev/null and b/images/unit_off.png differ diff --git a/images/unit_on.png b/images/unit_on.png new file mode 100644 index 0000000..6ffddd2 Binary files /dev/null and b/images/unit_on.png differ diff --git a/smartapps/piratemedia/smartthings/virtual-thermostat-with-device.src/virtual-thermostat-with-device.groovy b/smartapps/piratemedia/smartthings/virtual-thermostat-with-device.src/virtual-thermostat-with-device.groovy index 877bb06..c6884e8 100644 --- a/smartapps/piratemedia/smartthings/virtual-thermostat-with-device.src/virtual-thermostat-with-device.groovy +++ b/smartapps/piratemedia/smartthings/virtual-thermostat-with-device.src/virtual-thermostat-with-device.groovy @@ -10,34 +10,59 @@ definition( ) preferences { - section("Choose a temperature sensor(s)... (If multiple sensors are selected, the average value will be used)"){ + section("Choose temperature sensor(s)... (If multiple sensors are selected, the average value will be used)"){ input "sensors", "capability.temperatureMeasurement", title: "Sensor", multiple: true } - section("Select the heater outlet(s)... "){ - input "outlets", "capability.switch", title: "Outlets", multiple: true + section("Choose humidity sensor(s)... (optional, if multiple sensors are selected, the average value will be used)"){ + input "humidity_sensors", "capability.relativeHumidityMeasurement", title: "Humidity Sensors", multiple: true, required: false } - section("Only heat when contact(s) arent open (optional, leave blank to not require contact sensor)..."){ - input "motion", "capability.contactSensor", title: "Contact", required: false, multiple: true + section("Select the heater outlet(s)... (optional, leave blank if heating not required)"){ + input "heating_outlets", "capability.switch", title: "Heating Outlets", multiple: true, required: false + } + section("Select the cooling outlet(s)... (optional, leave blank if cooling not required)"){ + input "cooling_outlets", "capability.switch", title: "Cooling Outlets", multiple: true, required: false + } + section("Only heat/cool when contact(s) aren't open (optional, leave blank to not require contact sensor)..."){ + input "motion", "capability.contactSensor", title: "Motion Contact", required: false, multiple: true + } + section("Button/switch to trigger the smart heat function (optional, good for controlling thermostat from physical buttons or from virtual buttons that can be triggered by other apps or voice commands)"){ + input "smart_heat_button", "capability.switch", title: "Smart heat button", required: false + } + section("Button/switch to trigger the smart cool function (optional, good for controlling thermostat from physical buttons or from virtual buttons that can be triggered by other apps or voice commands)"){ + input "smart_cool_button", "capability.switch", title: "Smart cool button", required: false } section("Never go below this temperature: (optional)"){ - input "emergencySetpoint", "decimal", title: "Emergency Temp", required: false + input "emergencyHeatingSetpoint", "decimal", title: "Emergency Min Temp", required: false } - section("Temperature Threshold (Don't allow heating to go above or bellow this amount from set temperature)") { - input "threshold", "decimal", title: "Temperature Threshold", required: false, defaultValue: 1.0 + section("Never go above this temperature: (optional)"){ + input "emergencyCoolingSetpoint", "decimal", title: "Emergency Max Temp", required: false + } + section("The minimum difference between the heating and cooling setpoint, it's recommended to not put this too low to conserve energy") { + input "heatCoolDelta", "decimal", title: "Heat / Cool Delta", defaultValue: 3.0 } -} + section("The amount that the temperature is allowed to dip below the heating setpoint before engaging heating, it's recommended to not put this too low to avoid heaters turning on and off too frequently") { + input "heatDiff", "decimal", title: "Heat Differential", defaultValue: 0.3 + } + section("The amount that the temperature is allowed to go above the cooling setpoint before engaging cooling, it's recommended to not put this too low to avoid coolers turning on and off too frequently") { + input "coolDiff", "decimal", title: "Cool Differential", defaultValue: 0.3 + } + + section("Fix for unreliable switches to automatically turn them on/off again, if it seems like turning them on/off did not work based on the temperature (Experimental)") { + input "unreliableSwitchFix", "bool", title: "Unreliable switch fix", defaultValue: false + } +} def installed() { log.debug "running installed" state.deviceID = Math.abs(new Random().nextInt() % 9999) + 1 + updated() } def createDevice() { def thermostat def label = app.getLabel() - log.debug "create device with id: pmvt$state.deviceID, named: $label" //, hub: $sensor.hub.id" try { thermostat = addChildDevice("piratemedia/smartthings", "Virtual Thermostat Device", "pmvt" + state.deviceID, null, [label: label, name: label, completedSetup: true]) @@ -47,60 +72,68 @@ def createDevice() { return thermostat } +def motionDetected(){ + if(motion) { + for(m in motion) { + if(m.currentValue('contact') == "open") { + return true; + } + } + } + return false; +} + + def shouldHeatingBeOn(thermostat) { - //if temperature is bellow emergency setpoint - if(emergencySetpoint && emergencySetpoint > getAverageTemperature()) { + def temp = getAverageTemperature() + + //if temperature is below emergency setpoint + if(emergencyHeatingSetpoint && emergencyHeatingSetpoint > temp) { return true; } - //if thermostat isnt set to heat - if(thermostat.currentValue('thermostatMode') != "heat") { + //if thermostat isn't set to heat + if(thermostat.currentValue('thermostatMode') != "heat" && thermostat.currentValue('thermostatMode') != "auto") { return false; } //if any of the contact sensors are open - if(motion) { - for(m in motion) { - if(m.currentValue('contact') == "open") { - return false; - } - } + if(motionDetected()){ + return false; } - - //average temperature across all temperateure sensors is above set point - if (thermostat.currentValue("heatingSetpoint") - getAverageTemperature() <= threshold) { - return false; + + //average temperature across all temperature sensors is above set point + if(temp > thermostat.currentValue("adjustedHeatingPoint")) { + return false; } - + return true; } -def getHeatingStatus(thermostat) { - //if temperature is bellow emergency setpoint - if(emergencySetpoint > getAverageTemperature()) { - return 'heating'; +def shouldCoolingBeOn(thermostat) { + def temp = getAverageTemperature() + + //if temperature is above emergency setpoint + if(emergencyCoolingSetpoint && emergencyCoolingSetpoint < temp) { + return true; } - //if thermostat isnt set to heat - if(thermostat.currentValue('thermostatMode') != "heat") { - return 'idle'; + //if thermostat isn't set to cool + if(thermostat.currentValue('thermostatMode') != "cool" && thermostat.currentValue('thermostatMode') != "auto") { + return false; } //if any of the contact sensors are open - if(motion) { - for(m in motion) { - if(m.currentValue('contact') == "open") { - return 'pending heat'; - } - } + if(motionDetected()){ + return false; } - //average temperature across all temperateure sensors is above set point - if (thermostat.currentValue("thermostatSetpoint") - getAverageTemperature() <= threshold) { - return 'idle'; + //average temperature across all temperature sensors is below set point + if(temp < thermostat.currentValue("adjustedCoolingPoint")) { + return false; } - - return 'heat'; + + return true; } def getAverageTemperature() { @@ -118,26 +151,212 @@ def getAverageTemperature() { return total / count } -def handleChange() { - def thermostat = getThermostat() - //update device - thermostat.setHeatingStatus(getHeatingStatus(thermostat)) - thermostat.setVirtualTemperature(getAverageTemperature()) +def getAverageHumidity() { + def total = 0; + def count = 0; - //set heater outlet - if(shouldHeatingBeOn(thermostat)) { - outlets.on() + //total all sensors temperature + for(sensor in humidity_sensors) { + total += sensor.currentValue("humidity") + thermostat.setIndividualTemperature(sensor.currentValue("humidity"), count, sensor.label) + count++ + } + + //only divide by number of sensors if there are more than 0 + if(count > 0) { + return total / count } else { - outlets.off() + return 0 + } +} + +def switchOff(switches) { + if(switches) { + log.debug "switching off: ${switches}, current values: " + switches.currentValue("switch") + for(s in switches) { + s.off() + } + log.debug "done switching off: ${switches}, current values: " + switches.currentValue("switch") + } else { + log.debug "nothing to switch off" + } +} + +def switchOn(switches) { + if(switches) { + log.debug "switching on: ${switches}, current values: " + switches.currentValue("switch") + for(s in switches) { + s.on() + } + log.debug "done switching on: ${switches}, current values: " + switches.currentValue("switch") + } else { + log.debug "nothing to switch on" + } +} + +//set the expected direction (heat/cool/none) to be able to monitor if it's working +def setExpectedDirection(direction) { + log.debug "direction change to ${direction}" + state.expectedDirection = direction + state.tempAtDirectionChange = state.curTemp + state.directionChangeTime = new Date().getTime() +} + +def temperatureHandler(evt) { + state.curTemp = getAverageTemperature() + def now = new Date().getTime() + def minSinceDirectionChange = (now - state.directionChangeTime)/(1000*60) + log.debug "temperatureHandler: ${evt.stringValue}, curTemp: ${state.curTemp}" + + ", expectedDirection: ${state.expectedDirection}" + + ", minSinceDirectionChange: ${minSinceDirectionChange}, now: ${now}, directionChangeTime: ${state.directionChangeTime}, tempAtDirectionChange: ${state.tempAtDirectionChange}" + + if(state.expectedDirection != 'none') { + def directionChangeWorked = false; + if(state.expectedDirection == 'cool') { + directionChangeWorked = state.curTemp < state.tempAtDirectionChange + } + if(state.expectedDirection == 'heat') { + directionChangeWorked = state.curTemp > state.tempAtDirectionChange + } + + if(minSinceDirectionChange > 8 && minSinceDirectionChange < 16){ + //If we at any point in the in the period 8-16 min after a direction change see that the temperature is not trending in the right direction, we try to press the button again to ensure that it's truly pressed. + //This is specifically to fix unreliable switches such as switchbot + if(!directionChangeWorked) { + if(!unreliableSwitchFix) { + log.debug "direction change did not work within 8 min, but since 'Unreliable Switch Fix' is off, nothing will be done. Minutes since direction change: ${minSinceDirectionChange}" + } else { + log.debug "direction change did not work within 8 min, try flipping the switch again and reset the timer. Minutes since direction change: ${minSinceDirectionChange}" + setExpectedDirection(state.expectedDirection) //resets time and temp + def oState = thermostat.getOperatingState() + if(state.expectedDirection == 'cool') { + if(oState == 'cooling') { + switchOn(cooling_outlets) + } else { + switchOff(heating_outlets) + } + } + if(state.expectedDirection == 'heat') { + if(oState == 'heating') { + switchOn(heating_outlets) + } else { + switchOff(cooling_outlets) + } + } + } + } + } + } + + handleChange() +} + + +def cool() { + //log.debug "cooling outlets on, current value: " + cooling_outlets.currentValue("switch") + def oState = thermostat.getOperatingState() + if(oState != 'cooling') { + setExpectedDirection('cool') + thermostat.setThermostatOperatingState('cooling') + switchOn(cooling_outlets) + if(oState == 'heating') { + switchOff(heating_outlets) + } + } +} + +def heat() { + //log.debug "heating outlets on, current value: " + heating_outlets.currentValue("switch") + def oState = thermostat.getOperatingState() + if(oState != 'heating') { + setExpectedDirection('heat') + thermostat.setThermostatOperatingState('heating') + switchOn(heating_outlets) + if(oState == 'cooling') { + switchOff(cooling_outlets) + } + } +} + +def off() { + //log.debug "off, all outlets off, current value heating: " + heating_outlets.currentValue("switch") + ", cooling: " + cooling_outlets.currentValue("switch") + def oState = thermostat.getOperatingState() + if(oState != 'off') { + thermostat.setThermostatOperatingState('off') + setExpectedDirection('none') + if(oState == 'heating') { + switchOff(heating_outlets) + } else if(oState == 'cooling') { + switchOff(cooling_outlets) + } + } +} + +def idle() { + //log.debug "idle, all outlets off, current value heating: " + heating_outlets.currentValue("switch") + ", cooling: " + cooling_outlets.currentValue("switch") + def oState = thermostat.getOperatingState() + if(oState != 'idle') { + thermostat.setThermostatOperatingState('idle') + if(oState == 'heating') { + setExpectedDirection('cool') + switchOff(heating_outlets) + } else if(oState == 'cooling') { + setExpectedDirection('heat') + switchOff(cooling_outlets) + } + } +} + +def handleChange() { + def thermostat = getThermostat() + if(thermostat) { + log.debug "handle change, mode: " + thermostat.currentValue('thermostatMode') + + ", operatingState: " + thermostat.currentValue("thermostatOperatingState") + + ", temp: " + getAverageTemperature() + + ", coolingSetPoint: " + thermostat.currentValue("coolingSetpoint") + + ", heatingSetPoint: " + thermostat.currentValue("heatingSetpoint") + + /*def attrs = thermostat.supportedAttributes + attrs.each { + log.debug "${thermostat.displayName}, attribute: ${it.name}, dataType: ${it.dataType}, value: " + thermostat.currentValue(it.name) + }*/ + + switch (thermostat.currentValue('thermostatMode')){ + case "heat": + if(shouldHeatingBeOn(thermostat)) { + heat() + } else { + idle() + } + break + case "cool": + if(shouldCoolingBeOn(thermostat)) { + cool() + } else { + idle() + } + break + case "auto": + if(shouldCoolingBeOn(thermostat)) { + cool() + } else if(shouldHeatingBeOn(thermostat)) { + heat() + } else { + idle() + } + break + case "off": + default: + off() + break + } + getThermostat().setVirtualTemperature(getAverageTemperature()) } } def getThermostat() { - def child = getChildDevices().find { - d -> d.deviceNetworkId.startsWith("pmvt" + state.deviceID) - } - return child + return getChildDevice("pmvt" + state.deviceID) } def uninstalled() { @@ -156,37 +375,79 @@ def updated() thermostat = createDevice() } - //subscribe to temperatuire changes + //subscribe to temperature changes subscribe(sensors, "temperature", temperatureHandler) + //subscribe to humidity changes + if(humidity_sensors) { + subscribe(humidity_sensors, "humidity", humidityHandler) + } + //subscribe to contact sensor changes - if (motion) { + if(motion) { subscribe(motion, "contact", motionHandler) } - + + //smart heat and cool from input switches + if(smart_heat_button) { + subscribe(smart_heat_button, "switch", smartHeatHandler) + } + if(smart_cool_button) { + subscribe(smart_cool_button, "switch", smartCoolHandler) + } + //subscribe to virtual device changes - subscribe(thermostat, "thermostatSetpoint", thermostatTemperatureHandler) + subscribe(thermostat, "heatingSetpoint", heatingSetPointHandler) + subscribe(thermostat, "coolingSetpoint", coolingSetPointHandler) subscribe(thermostat, "thermostatMode", thermostatModeHandler) //reset some values + setExpectedDirection('none') thermostat.clearSensorData() thermostat.setVirtualTemperature(getAverageTemperature()) + thermostat.setVirtualHumidity(getAverageHumidity()) + thermostat.setHeatCoolDelta(heatCoolDelta) + thermostat.setHeatDiff(heatDiff) + thermostat.setCoolDiff(coolDiff) } -def temperatureHandler(evt) -{ - handleChange() +def coolingSetPointHandler(evt) { + log.debug "coolingSetPointHandler: ${evt.stringValue}" + handleChange() } -def motionHandler(evt) -{ - handleChange() +def heatingSetPointHandler(evt) { + log.debug "heatingSetPointHandler: ${evt.stringValue}" + handleChange() } -def thermostatTemperatureHandler(evt) { - handleChange() +def motionHandler(evt) { + log.debug "motionHandler: ${evt.stringValue}" + handleChange() } def thermostatModeHandler(evt) { + log.debug "thermostatModeHandler: ${evt.stringValue}" handleChange() +} + +def humidityHandler(evt) { + log.debug "humidityHandler: ${evt.stringValue}" + thermostat.setVirtualHumidity(getAverageHumidity()) +} + +def smartHeatHandler(evt) { + log.debug "smartHeatHandler: ${evt.stringValue}" + if(smart_heat_button.currentValue('switch') == "on") { + getThermostat().smartHeatUp() + smart_heat_button.off() + } +} + +def smartCoolHandler(evt) { + log.debug "smartCoolHandler: ${evt.stringValue}" + if(smart_cool_button.currentValue('switch') == "on") { + getThermostat().smartCoolDown() + smart_cool_button.off() + } } \ No newline at end of file