Develop a driver - (Venstar Thermostat Driver Development)

V0.8 installed and working.

How is the Device Poll Rate important here ? To reinject variations into HE (dashboard/tile but mostly rules) ?

I won't touch physically the thermostat settings for now (all through HE), so I think/understand that my settings changes will come from HE, and not to HE, excepts values measured (temperatures). The poll timing will show on the tile what values changed. Am I right ?

this little monster is more than this. It's the blackbox that returns the value of the hidden thermistor to the thermostat, using the build-in wifi. My setup is just a physical connection between the thermostat and the sensor

1 Like

gender edited in my previous post :smiley:

1 Like

You can be proud My wife thanks you too ! The WAF increased !

Next time: dynamic tiles with global variables:

.

1 Like

The way the driver works is:

  1. The driver starts up (runs the initialize() function. Polls the thermostats for all the important information(temp/setpoints/mode/state/etc). This is a snapshot of the thermostats environment at that specific point in time. Schedule a poll for some time in the future.
  2. wait for user input.
  3. Poll the thermostat at a specified point in time. Update the device attributes with the retrieved information.
  4. go to step 2.

When user input is received (any tile/device button, etc) the driver processes the command and, using postapi(), sends it to the thermostat. It then polls the thermostat to get the new state of the thermostat.

Meanwhile, the temperature changes, or someone changes the mode/setpoint/etc at the thermostat. The thermostat does not send this information to the driver.... The driver must poll the thermostat to discover these changes...

The poll rate determine how often the driver polls the thermostat... If depends on how "responsive" you want the HE system (Rule Machine, dashboards, etc) to be... If you set the poll rate to 1 minute, then the HE can respond quickly to changes in temperature or inappropriate changes to setpoints. If you set the poll to every 3 hours, the HE can not respond to changes as quickly... The correct setting depends entirely on your needs...

The information passed to/from the thermostat is a very small amount of data. More frequent polling should not adversly affect your system.

You're on your own for that 8-}

i'm a chatbot!

2 Likes

1 Like

Here is another update featuring:

  • various bug fixes
  • added "Presence Sensor" capability to track Home (present)/ Away (not present) status on the thermostat
  • added "Relative Humidity Measurement" capability to allow a built-in humidity sensor (if available on the thermostat) to provide data to a dashboard "humidity" tile
  • "Enable sensor child devices" parameter that create child devices for external temperature and humidity sensor that are connected to the thermostat
/**
 *  Venstar Hubitat Driver
 *
 *  Copyright 2014 Scottin Pollock
 *  Modifications copyright (C) 2019 David Tarin
 *  Fixes, refactoring & functionality copyright (C) 2019 CybrMage
 *
 *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 *
 */

import groovy.json.JsonSlurper

metadata {
	definition (name: "Venstar Thermostat", namespace: "Thermostat", author: "CybrMage, Scottin Pollock, David Tarin") {
		capability "Actuator"
		capability "Initialize"
		capability "Polling"
		capability "Refresh"
		capability "Sensor"
		capability "Thermostat"
		capability "RelativeHumidityMeasurement"
		capability "PresenceSensor"
	}

	preferences {
		section("Driver config") {
			input "thermostatIP", "text", title: "Thermostat IP", required: false
			def pollRate = ["1" : "Poll every minute", "5" : "Poll every 5 minutes", "10" : "Poll every 10 minutes", "15" : "Poll every 15 minutes", "30" : "Poll every 30 minutes", "60" : "Poll every hour", "180" : "Poll every 3 hours"]
			input ("Poll_Rate", "enum", title: "Device Poll Rate", options: pollRate, defaultValue: "10")
			input name: "sensorEnable", type: "bool", title: "Enable sensor child devices", defaultValue: false
			input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
			input name: "testMode", type: "bool", title: "Enable offline test mode", defaultValue: false
		}
	}
}

private getVERSION() { "v0.9" }

// parse events into attributes
def parseJsonData(result) {
	log.debug("parseJsonData: data is: $result")
	if (result.success != null){
		//Do nothing as nothing can be done. (runIn doesn't appear to work here and apparently you can't make outbound calls here)
		if (logEnable) log.debug "parseJsonData: getapi()/postapi() data indicates success"
		return
	}
	if (result.error != null){
		log.debug "parseJsonData: getapi()/postapi() data indicates error: ${result.reason}"
		return
	}
	if (result.mode != null){
		log.debug "parseJsonData: parsing result.mode"
		def mode = getModeMap()[result.mode]
		state.thermostatMode = result.mode
		if(device.currentState("thermostatMode")?.value != mode){
			sendEvent(name: "thermostatMode", value: mode, descriptionText: "thermostatMode set to ${mode}", isStateChange: true)
		}
	}
	if (result.state != null){
		log.debug "parseJsonData: parsing result.state"
		def mode = getOperatingModeMap()[result.state]
		state.thermostatOperatingState = result.state
		if(device.currentState("thermostatOperatingState")?.value != mode){
			sendEvent(name: "thermostatOperatingState", value: mode, descriptionText: "thermostatOperatingState set to ${mode}", isStateChange: true)
		}
	}
	if (result.fan != null){
		log.debug "parseJsonData: parsing result.fan"
		def fan = getFanModeMap()[result.fan]
		state.thermostatFanMode = result.fan
		if (device.currentState("thermostatFanMode")?.value != fan){
			sendEvent(name: "thermostatFanMode", value: fan, descriptionText: "thermostatFanMode set to ${fan}", isStateChange: true)
		}
	}
	if (result.fanstate != null){
		log.debug "parseJsonData: parsing result.fanstate"
		def mode = (result.fanstate == 0)?"off":"on"
		state.thermostatFanOperatingState = result.fanstate
		if(device.currentState("thermostatFanOperatingState")?.value != mode){
			sendEvent(name: "thermostatFanOperatingState", value: mode, descriptionText: "thermostatFanOperatingState set to ${mode}", isStateChange: true)
		}
	}
	if (result.tempunits != null){
		log.debug "parseJsonData: parsing result.tempunits"
		def temperatureScale = ((result.tempunits as Integer) == 0) ? "F" : "C"
		state.temperatureScale = temperatureScale
		if (device.currentState("temperatureScale")?.value != temperatureScale.toString()){
			sendEvent(name: "temperatureScale", value: temperatureScale, descriptionText: "temperatureScale set to ${temperatureScale}", isStateChange: true)
		}
	} else {
		if (result.spacetemp != null){
			def temp = result.spacetemp
			if (temp < 32) {
				state.temperatureScale = "C"
			} else {
				state.temperatureScale = "F"
			}
		} else {
			state.temperatureScale = "F"
		}
		sendEvent(name: "temperatureScale", value: temperatureScale, descriptionText: "temperatureScale set to ${temperatureScale}", isStateChange: true)
	}
	if (result.cooltempmin != null){
		log.debug "parseJsonData: parsing result.cooltempmin"
		state.cooltempmin = result.cooltempmin
	}
	if (result.cooltempmax != null){
		log.debug "parseJsonData: parsing result.cooltempmax"
		state.cooltempmax = result.cooltempmax
	}
	if (result.heattempmin != null){
		log.debug "parseJsonData: parsing result.heattempmin"
		state.heattempmin = result.heattempmin
	}
	if (result.heattempmax != null){
		log.debug "parseJsonData: parsing result.heattempmax"
		state.heattempmax = result.heattempmax
	}
	if (result.cooltemp != null){
		log.debug "parseJsonData: parsing result.cooltemp"
		def cooltemp = (getTemperatureHE(result.cooltemp) as Float).round(1)
		state.cooltemp = result.cooltemp
		if (device.currentState("coolingSetpoint")?.value != cooltemp.toString()){
			sendEvent(name: "coolingSetpoint", value: cooltemp, descriptionText: "coolingSetpoint set to ${cooltemp}", isStateChange: true)
		}
	}
	if (result.heattemp != null){
		log.debug "parseJsonData: parsing result.heattemp"
		def heattemp = (getTemperatureHE(result.heattemp) as Float).round(1)
		state.heattemp = result.heattemp
		if (device.currentState("heatingSetpoint")?.value != heattemp.toString()){
			sendEvent(name: "heatingSetpoint", value: heattemp, , descriptionText: "Heating setpoint set to ${heattemp}", isStateChange: true)
		}
	}
	if (result.spacetemp != null){
		log.debug "parseJsonData: parsing result.spacetemp"
		def temp = (getTemperatureHE(result.spacetemp) as Float).round(1)
		state.temperature = result.spacetemp
		if (device.currentState("temperature")?.value != spacetemp.toString()){
			sendEvent(name: "temperature", value: temp, descriptionText: "temperature set to ${temp}", isStateChange: true)
		}
	}
	if (result.state != null){
		if ( state.thermostatOperatingState == 2) {
			state.thermostatSetpoint = state.cooltemp
		} else {
			state.thermostatSetpoint = state.heattemp
		}
	    sendEvent(name: "thermostatSetpoint", value: (getTemperatureHE(state.thermostatSetpoint) as Float).round(1), descriptionText: "thermostatSetpoint set to ${(getTemperatureHE(state.thermostatSetpoint) as Float).round(1)}", isStateChange: true)
	}
	if (result.away != null){
		log.debug "parseJsonData: parsing result.away"
		def mode = getAwayModes()[result.away]
		state.presence = result.away
		if(device.currentState("presence")?.value != mode){
			sendEvent(name: "presence", value: mode, descriptionText: "presence set to ${mode}", isStateChange: true)
		}
	}
	if (result.hum != null){
		log.debug "parseJsonData: parsing result.hum"
		state.humidity = result.hum
		if(device.currentState("humidity")?.value != mode){
			sendEvent(name: "humidity", value: state.humidity, descriptionText: "humidity set to ${mode}", isStateChange: true)
		}
	}
	if (result.sensors != null) {
		log.debug "parseJsonData: parsing SENSOR data"
		result.sensors.each { sensor ->
			def sensorDNI = device.deviceNetworkId + "-ep" + sensor.name.replaceAll("\\s","").replaceAll("\\W","")
			def targetChild = getChildDevice(sensorDNI)
			if (targetChild != null) {
				log.debug "parseJsonData: updating SENSOR data for "+sensorDNI
				if (sensor.temp != null) targetChild.setTemperature(sensor.temp)
				if (sensor.hum != null) targetChild.setHumidity(sensor.hum)
			} else {
				log.warn "parseJsonData: child SENSOR does not exist for "+sensorDNI
			}
		}
	}
}

def poll() {
  log.debug("poll: Executing 'poll'")
  sendEvent(descriptionText: "poll keep alive", isStateChange: false)  // workaround to keep polling from being shut off
  refresh()
}

def modes() {
  ["off", "heat", "cool", "auto"]
}

def parseDescriptionAsMap(description) {
  description.split(",").inject([:]) { map, param ->
    def nameAndValue = param.split(":")
    map += [(nameAndValue[0].trim()):nameAndValue[1].trim()]
  }
}

def getAwayModes() { [
  0:"present",
  1:"not present"
]}

def getModeMap() { [
  0:"off",
  2:"cool",
  1:"heat",
  3:"auto"
]}

def getOperatingModeMap() { [
	0: "Idle",
	1: "Heating",
	2: "Cooling",
	3: "Lockout",
	4: "Error"
]}

def getFanModeMap() {[
	0:"fanAuto",
	1:"fanOn"
]}

def clampTemp(temp) {
	def T1 = Math.floor(temp)
	def T2 = temp - T1
	def T3 = 0
	if ((T2 >= 0.25) && (T2 <= 0.75)) {
		T3 = 0.5
	} else if (T2 > 0.75) {
		T3 = 1.0
	}
	def T4 = T1 + T3
	if (logEnable) log.debug("  clampTemp: triming value ${temp} to ${T4}")
	return T4
}

def getTemperatureHE(value) {
	if (logEnable) log.debug("  getTemperatureHE: testing for temperature scale conversion for temperature value ${value}")
	def scaleHE = getTemperatureScale()
	def scaleVS = (device.currentState("temperatureScale")?.value)?(device.currentState("temperatureScale")?.value):state.temperatureScale
	log.debug("  getTemperatureHE: temperatureScale - HE = ${scaleHE}  VS = ${scaleVS}")
	if ( !scaleHE || !scaleVS || (scaleHE == scaleVS)) {
		value = clampTemp(value)
		if (logEnable) log.debug("  getTemperatureHE: no temperature scale conversion required for temperature value ${value}")
		return value
	}
	if ((scaleVS == "F") && (scaleHE == "C")) {
		// F to C
		def tempC = (((value-32)*5.0)/9.0)
		tempC = clampTemp(tempC)
		if (logEnable) log.warn("  getTemperatureHE: temperature scale conversion required. temperature value ${value}F = ${tempC}C")
		return tempC
	} else {
		// C to F
		def tempF = (((value * 9.0)/5.0)+32)
		tempF = clampTemp(tempF)
		if (logEnable) log.warn("  getTemperatureHE: temperature scale conversion required. temperature value ${value}C = ${tempF}F")
		return tempF
	}
}

def getTemperatureVS(value) {
	if (logEnable) log.debug("  getTemperatureVS: testing for temperature scale conversion for temperature value ${value}")
	def scaleHE = getTemperatureScale()
	def scaleVS = (device.currentState("temperatureScale")?.value)?(device.currentState("temperatureScale")?.value):state.temperatureScale
	log.debug("  getTemperatureVS: temperatureScale - VS = ${scaleVS}  HE = ${scaleHE}")
	if ( !scaleHE || !scaleVS || (scaleHE == scaleVS)) {
		value = clampTemp(value)
		if (logEnable) log.debug("  getTemperatureVS: no temperature scale conversion required for temperature value ${value}")
		return value
	}
	if ((scaleHE == "F") && (scaleVS == "C")) {
		// F to C
		def tempC = (((value-32)*5.0)/9.0)
		tempC = clampTemp(tempC)
		if (logEnable) log.warn("  getTemperatureVS: temperature scale conversion required. temperature value ${value}F = ${tempC}C")
		return tempC
	} else {
		// C to F
		def tempF = (((value * 9.0)/5.0)+32)
		tempF = clampTemp(tempF)
		if (logEnable) log.warn("  getTemperatureVS: temperature scale conversion required. temperature value ${value}C = ${tempF}F")
		return tempF
	}
}

// handle commands
def getValidHeatSetpoint(newTemp) {
	if (logEnable) log.debug("  getValidHeatSetpoint: testing heat setpoint for temperature value ${newTemp}")
	if (!state.heattempmin || !state.heattempmax) { 
		if (logEnable) log.debug("  getValidHeatSetpoint: no restriction for temperature value ${newTemp}")
		return newTemp 
	}
	def minTemp = state.heattempmin as Integer
	def maxTemp = state.heattempmax as Integer
	if (newTemp < minTemp) {
		if (logEnable) log.warn("  getValidHeatSetpoint: heat setpoint restricted to minimum temperature value ${minTemp}")
		return minTemp
	} else if (newTemp > maxTemp) {
		if (logEnable) log.warn("  getValidHeatSetpoint: heat setpoint restricted to maximum temperature value ${maxTemp}")
		return maxTemp
	} else {
		if (logEnable) log.debug("  getValidHeatSetpoint: no restriction for temperature value ${newTemp}")
		return newTemp
	}
}

def getValidCoolSetpoint(newTemp) {
	if (logEnable) log.debug("  getValidCoolSetpoint: testing cool setpoint for temperature value ${newTemp}")
	if (!state.cooltempmin || !state.cooltempmax) { 
		if (logEnable) log.debug("getValidCoolSetpoint: no restriction for temperature value ${newTemp}")
		return newTemp 
	}
	def minTemp = state.cooltempmin as Integer
	def maxTemp = state.cooltempmax as Integer
	if (newTemp < minTemp) {
		if (logEnable) log.warn("  getValidCoolSetpoint: cool setpoint restricted to minimum temperature value ${minTemp}")
		return minTemp
	} else if (newTemp > maxTemp) {
		if (logEnable) log.warn("  getValidCoolSetpoint: cool setpoint restricted to maximum temperature value ${maxTemp}")
		return maxTemp
	} else {
		if (logEnable) log.debug("  getValidCoolSetpoint: no restriction for temperature value ${newTemp}")
		return newTemp
	}
}

def setCommonSetpoint() {
	if ( state.thermostatOperatingState == 2) {
		state.thermostatSetpoint = state.cooltemp
	} else {
		state.thermostatSetpoint = state.heattemp
	}
	def newTemp = clampTemp(getTemperatureHE(state.thermostatSetpoint))
	if (logEnable) log.debug "  setCommonSetpoint: Executing 'setCommonSetpoint' with ${newTemp} (originally - ${state.thermostatSetpoint})"
    sendEvent(name: "thermostatSetpoint", value: newTemp, descriptionText: "thermostatSetpoint set to ${newTemp}", isStateChange: true)
}

def setHeatingSetpoint(degrees) {
	if (logEnable) log.debug "Executing 'setHeatingSetpoint' with ${degrees}"
	def degreesHE = clampTemp(degrees)
	def degreesVS = getTemperatureVS(degreesHE)
	state.heattemp = getValidHeatSetpoint(degreesVS)
	if (state.heattemp != degreesVS) {
		// temperature was restricted - convert restricted temperature back to HE format
		degreesHE = getTemperatureHE(degreesVS)
	}
	if (logEnable) log.debug "processing 'setHeatingSetpoint' with ${degreesHE} (originally ${degrees})"
	setCommonSetpoint()
	sendEvent(name: "heatingSetpoint", value: degreesHE, descriptionText: "Heating setpoint set to ${degreesHE}", isStateChange: true)
	postapi()
}

def setCoolingSetpoint(degrees) {
	if (logEnable) log.debug "Executing 'setCoolingSetpoint' with ${degrees}"
	def degreesHE = clampTemp(degrees)
	def degreesVS = getTemperatureVS(degreesHE)
	state.cooltemp = getValidCoolSetpoint(degreesVS)
	if (state.cooltemp != degreesVS) {
		// temperature was restricted - convert restricted temperature back to HE format
		degreesHE = getTemperatureHE(degreesVS)
	}
	if (logEnable) log.debug "processing 'setCoolingSetpoint' with ${degreesHE} (originally ${degrees})"
	sendEvent(name: "coolingSetpoint", value: degreesHE, descriptionText: "Cooling setpoint set to ${degreesHE}", isStateChange: true)
	setCommonSetpoint()
	postapi()
}

def off() {
	log.debug "Executing 'off'"
	state.thermostatMode = 0
	sendEvent(name: "thermostatMode", value: getModeMap()[0], descriptionText: "Thermostat Mode set to ${getModeMap()[0]}", isStateChange: true)
	postapi()
}

def heat() {
	log.debug "Executing 'heat'"
	state.thermostatMode = 1
	sendEvent(name: "thermostatMode", value: getModeMap()[1], descriptionText: "Thermostat Mode set to ${getModeMap()[1]}", isStateChange: true)
	postapi()
}

def cool() {
	log.debug "Executing 'cool'"
	state.thermostatMode = 2
	sendEvent(name: "thermostatMode", value: getModeMap()[2], descriptionText: "Thermostat Mode set to ${getModeMap()[2]}", isStateChange: true)
	postapi()
}

def auto() {
	log.debug "Executing 'auto'"
	state.thermostatMode = 3
	sendEvent(name: "thermostatMode", value: getModeMap()[3], descriptionText: "Thermostat Mode set to ${getModeMap()[3]}", isStateChange: true)
	postapi()
}

def emergencyHeat() {
	log.error "'emergencyHeat' is not supported by this device"
}

def setSchedule(String json) {
	log.error "'setSchedule' is not supported by this device driver"
}

def setThermostatMode(String newMode) {
	log.debug("setThermostatMode(${newMode})")
	switch(newMode) {
		case "off":
			off()
			break
		case "heat":
			heat()
			break
		case "cool":
			cool()
			break
		case "auto":
			auto()
			break
		default:
			log.error("setThermostatMode: mode '${newMode}' is not supported by this device")
			break
	}
}

def fanOn() {
	log.debug "Executing 'fanOn'"
	state.thermostatFanMode = 1
	sendEvent(name: "thermostatFanMode", value: getFanModeMap()[1], descriptionText: "Thermostat Fan Mode set to ${getFanModeMap()[1]}", isStateChange: true)
	postapi()
}

def fanAuto() {
	log.debug "Executing 'fanAuto'"
	state.thermostatFanMode = 0
	sendEvent(name: "thermostatFanMode", value: getFanModeMap()[0], descriptionText: "Thermostat Fan Mode set to ${getFanModeMap()[0]}", isStateChange: true)
	postapi()
}

def fanCirculate() {
	log.error("'fanCirculate' is not supported by this device")
}

def setThermostatFanMode(String newMode) {
	log.debug("setThermostatFanMode(${newMode.trim()})")
	switch(newMode.trim()) {
		case "on":
		case "fanOn":
			fanOn()
			break
		case "auto":
		case "fanAuto":
			fanAuto()
			break
		default:
			log.error("setThermostatFanMode: mode '${newMode}' is not supported by this device")
			break
	}
}

def refresh() {
	log.debug "Executing 'refresh'"
	getapi()
	if (sensorEnable) {
		log.warn "Executing 'SENSOR refresh'"
		getSensors()
	}
}

private getapi() {
	def uri = "http://"+getHostAddress()+"/query/info"
	log.debug("getapi: Executing get api to " + uri)
	if (testMode) uri = "http://127.0.0.1/api/json/utc/now"

	try {
		def requestParams =
		[
			uri:  uri,
			requestContentType: "application/x-www-form-urlencoded",
			contentType: "application/json"
		]
		httpGet(requestParams) { resp ->
			if (resp.status == 200) {
				if (logEnable) log.debug "getapi: httpGet returned: \n${resp.data}"
				parseJsonData(resp.data)
			} else {
				log.debug("getapi: httpPost returned http failure code ${resp.status}")
			}
			if (logEnable) log.debug "getapi: getapi() completed"
		}
	} catch (Exception e) {
		if (testMode && (e.message == "Connect to 127.0.0.1:80 [/127.0.0.1] failed: Connection refused (Connection refused)")) {
			return parseJsonData(testDATA())
		}
		log.error "getapi: Call to getapi() failed: ${e.message}"
	}
}

private createModeChangeCommand(){
	def command = "mode=" + state.thermostatMode +
		"&fan=" + state.thermostatFanMode +
		"&heattemp=" + state.heattemp + 
		"&cooltemp=" + state.cooltemp
	return command
}

private postapi() {
	if (logEnable) log.info("postapi: Executing API Control update")
	def uri = "http://"+getHostAddress()+"/control"
	def command = createModeChangeCommand()

	if (logEnable) {
		if (testMode) {
			log.info("postapi: Test Mode: POST request to [${uri}] with command [${command}]")
		} else {
			log.info("postapi: Sending on POST request to [${uri}] with command [${command}]")
		}
	}
	if (testMode) {
		return getapi()
	}

	def postParams = [
		uri: uri,
		contentType: "application/json",
		requestContentType: "application/x-www-form-urlencoded",
		body : command
	]

	try {
		httpPost(postParams) { resp ->
			if (resp.status == 200) {
				if (logEnable) log.debug "postapi: httpPost returned: \n${resp.data}"
				if (resp.data.success == true) {
					if(logEnable) log.debug("postapi: httpGet returned success")
					// update the thermostat state
					getapi()
				} else{
					if(logEnable) log.debug("postapi: httpGet returned success")
				}
			} else {
				log.debug("postapi: httpPost returned http failure code ${resp.status}")
			}
		}
	} catch (Exception e) {
		log.error "postapi: Call to postapi(${command}) failed: ${e.message}"
	}
}

//helper methods
private getHostAddress() {
  return settings.thermostatIP
}

// driver default methods
def updated() {
  log.debug("updated: Venstar Driver ${VERSION} - Updating parameters")
  initialize()
}

def installed() {
  log.debug("installed: Venstar Driver ${VERSION} - Installed")
}

def initialize() {
	log.debug("initialize: Venstar Driver ${VERSION} - Initializing device ${device.getDeviceNetworkId()}")
	unschedule()
	sendEvent(name: "temperatureDisplayScale", value: getTemperatureScale(), descriptionText: "temperatureDisplayScale set to ${getTemperatureScale()}", isStateChange: true)
	sendEvent(name: "supportedThermostatFanModes", value: [fanAuto,fanOn], isStateChange: true)
	sendEvent(name: "supportedThermostatModes", value: [off,heat,cool,auto], isStateChange: true)
  
	if (getHostAddress()) {
		// get the initial thermostat state
		if (sensorEnable) {
			def sensorData = getSensors(true)
			createSensors(sensorData)
		} else {
			// remove child sensors if needed
			getChildDevices().each { sensor ->
				log.debug ("removing disabled sensor ${sensor.deviceNetworkId}")
				deleteChildDevice(sensor.deviceNetworkId)
			}
		}
		getapi()
	} else {
		log.debug("initialize: Venstar IP Address not set")
		return
	}
	def poll = Poll_Rate ? Poll_Rate : "10"
	switch(poll) {
		case "1" :
			runEvery1Minute(refresh)
			log.debug("initialize: Poll Rate set to every 1 minute")
			break
		case "5" :
			runEvery5Minutes(refresh)
			log.debug("initialize: Poll Rate set to every 5 minutes")
			break
		case "10" :
			runEvery10Minutes(refresh)
			log.debug("initialize: Poll Rate set to every 10 minutes")
			break
		case "15" :
			runEvery15Minutes(refresh)
			log.debug("initialize: Poll Rate set to every 15 minutes")
			break
		case "30" :
			runEvery30Minutes(refresh)
			log.debug("initialize: Poll Rate set to every 30 minutes")
			break
		case "60" :
			runEvery1hour(refresh)
			log.debug("initialize: Poll Rate set to every 1 hour")
			break
		case "30" :
			runEvery3hours(refresh)
			log.debug("initialize: Poll Rate set to every 3 hours")
			break
	}
}

private getSensors(dataOnly = false) {
	def uri = "http://"+getHostAddress()+"/query/sensors"
	log.debug("getapi: Executing get api to " + uri)
	if (testMode) uri = "http://127.0.0.1/query/sensors"

	try {
		def requestParams =
		[
			uri:  uri,
			requestContentType: "application/x-www-form-urlencoded",
			contentType: "application/json"
		]
		httpGet(requestParams) { resp ->
			if (resp.status == 200) {
				if (logEnable) log.debug "getSensors: httpGet returned: \n${resp.data}"
				parseJsonData(resp.data)
			} else {
				log.debug("getSensors: httpGet returned http failure code ${resp.status}")
			}
			if (logEnable) log.debug "getSensors: getSensors() completed"
		}
	} catch (Exception e) {
		if (testMode && (e.message == "Connect to 127.0.0.1:80 [/127.0.0.1] failed: Connection refused (Connection refused)")) {
			if (dataOnly) return testSensorDATA()
			return parseJsonData(testSensorDATA())
		}
		log.error "getSensors: Call to getSensors() failed: ${e.message}"
	}
}

def findChild(childDNI) {
	getChildDevices()?.find { it.deviceNetworkId == childDNI}
}

def createSensors(sensorData) {
	log.debug("createSensors: Parsing sensor data: " + sensorData)
	def parentDNI = device.getDeviceNetworkId()
	if (sensorData.sensors) {
		sensorData.sensors.each { sensor ->
			def sensorName = sensor.name.replaceAll("\\s","").replaceAll("\\W","")
			def sensorDNI = "${device.deviceNetworkId}-ep${sensorName}"
			if (sensor.temp != null) {
				log.debug("createSensors: Found temperature sensor device - " + sensor.name + "  DNI: "+sensorDNI)
				def childDevice = findChild(sensorDNI)
				if (childDevice == null) {
					log.debug("createSensors: creating temperature Child "+sensorDNI)
					childDevice = addChildDevice("hubitat", "Virtual Temperature Sensor", sensorDNI, [label: "${device.displayName} (temp sensor - ${sensor.name})", isComponent: false])
				} else {
					log.debug("createSensors: temperatureChild ${sensorDNI} already exists")
				}
				childDevice.setTemperature(sensor.temp)
			} else if (sensor.hum != null) {
				log.debug("createSensors: Found humidity sensor device - " + sensor.name + "  DNI: "+sensorDNI)
				def childDevice = findChild(sensorDNI)
				if (childDevice == null) {
					log.debug("createSensors: creating humidity Child "+sensorDNI)
					childDevice = addChildDevice("hubitat", "Virtual Humidity Sensor", "${device.deviceNetworkId}-ep${sensorName}", [label: "${device.displayName} (temp sensor - ${sensor.name})", isComponent: false])
				} else {
					log.debug("createSensors: humidity Child ${sensorDNI} already exists")
				}
				childDevice.setHumidity(sensor.hum)
			} else {
				log.debug("createSensors: Found unknown sensor device - " + sensor.name)
			}
		}
	}
}

def testDATA() { 
	def data = '{"name":"Test Data","mode":1,"state":0,"fan":0,"fanstate":1,"tempunits":1,"schedule":0,"schedulepart":255,"away":0,"spacetemp":21.0,"heattemp":21.0,"cooltemp":8.0,"cooltempmin":1.5,"cooltempmax":37.0,"heattempmin":1.5,"heattempmax":37.0,"setpointdelta":2.0,"availablemodes":1}'
	def slurper = new JsonSlurper()
	def result = slurper.parseText(data)
	result.mode = state.thermostatMode ?: 0
	result.state = state.thermostatMode ?: 0
	result.fan = state.thermostatFanMode ?: 0
	result.fanstate = (state.thermostatFanMode && (state.thermostatMode > 0)) ?: 0
	if (state.heattemp != null) result.heattemp = state.heattemp
	if (state.cooltemp!= null) result.cooltemp = state.cooltemp
	return result
}

def testAlertDATA() { 
	def data = '{"alerts": [{"name": "Air Filter","active": false},{"name": "UV Lamp","active": false},{"name": "Service","active": false}]}'
	def slurper = new JsonSlurper()
	def result = slurper.parseText(data)
	return result
}

def testSensorDATA() { 
	def data = '{"sensors": [{"name": "Thermostat","temp": 77},{"name": "Outdoor","temp": 0}]}'
	def slurper = new JsonSlurper()
	def result = slurper.parseText(data)
	return result
}
2 Likes

v0.9 installed and testing:
fan ON mode is OK.
After initializing, I obtained a lockout message instead of Idle, but now the thermostat is heating with the appropriate message.
But I'll stop my tests for now, outside temp is decreasing and my wife is not very happy with my tests (stop/heat/fan ON, etc). I'll try to replicate them tomorrow.

*Edit: maybe the lockout message was just after initialization because Idle is on display (lowered a little bit the temp asked to do my last test)

So far so good, more tests tomorrow !

So far, so good.
tested the latest version 0.9 (thermostat without external sensor hooked): OK, the thermostat tile and my custom switches are reacting as expected.
I'll reconnect my external sensor later in the day and see if everything is working. Without external sensor, the query (http://my_ip_address/query/sensors) returns:

{"sensors":[{"name":"Thermostat","temp":21.0},{"name":"Outdoor","temp":-39.0}]}

and it's a normal behaviour here. Will post results.
Thx

Installed my external sensor. Calibrated to be sure.
When I set this external sensor as a remote sensor, the thermostat takes the value of it as "the" sensor. Nothing changed on the device/app/tile and the right value is used as the current temperature. All is working normally.
That said, maybe @jabecker has a different setup because she uses the external sensor as a second thermistor and the thermostat is using both to define the average temperature.
For me, it's a substitution, for her, it's an addition.
But on my side, all seems perfect !

I don't have "enable sensor child device" turned on. And I keep forgetting to check at a time when the sensors are showing different temps to see if I can tell whether it's reporting the thermostat value or the average value. I'll try to remember to keep an eye on it.

ETA: It's showing the average of the two sensors as the current temperature. No doubt that's what the Venstar is reporting. That's fine. I can work with that. When I decide what to do with it. :crazy_face:

THANK YOU @cybrmage for that AWESOME WORK !
CC to @bravenel

2 Likes

For both of those situations, the Venstar thermostat settings determine how the external sensor is used. From what I have been able to determine, the external sensor can be set to 1) replace the built-in sensor (as PPz has it set), 2) average the two sensors (as jabecker has it set) or 3)as an independent additional sensor.

I haven't found any documentation that explains under what circumstances the sensor is reported by the API.

The driver only checks for external sensors when this option of turned on.

When the option is enabled, the driver will query the thermostat for sensors, and create a child device for each reported sensor. If you do not enable the option, the driver does not query the external sensors (as there is nowhere to put the data to make it accessible ).

Since the sensors populate child devices, you will not see any visible change to the Venstar parent device or dashboard tile associated with the tile... The only indication you will see is in the logs, when the driver creates the child devices. To access the data for these devices, you will need to either reference them in a rule or with a dashboard tile linked to the child devices.

Both external temperature and humidity sensors are supported.

1 Like

Latest version. Bug fixes and reduce logging (when debug log disabled)

UPDATE: removed old version.

When I enable the child device option, no child sensors are found. Possibly because the sensor I'm using is wifi vs wired? In any case, please don't try to "fix" it as it's not really important to me.

The documentation on all of this is a bit vague. :roll_eyes:

There are actually a number of ways to determine which sensors are used for what. Here's a screenshot of the sensor settings for mine in the Venstar cloud service:

I'm using "average all available," but in my case "Average wireless / thermostat" would be the same. I don't need to select a wireless sensor unless I use "single wireless." I don't have any wired external sensors, so whatever is selected in the bottom part is moot.

I believe that if a sensor is used as a control input for the thermostat, it is not available (reported) to the API... As you are using "average all", all of your "external" sensors are in use, and will not be reported by the API.

If you were to select "Thermostat sensor only", then the sensors would show up in the API and the driver would be able to use them. Type of sensor (wired vs WiFi) should not matter at all.

I'm seeing this error when it polls:

I reinstalled the previous version.

That makes sense. I'm happy with how it works with the average, so I don't think I'll change that. Having the average report in HE is fine.

Thanks for catching that!!... I need to stop coding when tired 8-}

New version soon...

New version. Bug Fixes.

.
UPDATE: removed old version.

1 Like