Overloading a Z-Wave Switch to act as thermostat -- won't actuate switch

Hey everyone. I’m trying to build in some basic thermostat capability on top of a generic Z-Wave Switch. I’ve spliced together the key pieces, and the only thing I haven’t gotten to work is the fundamental ability to toggle the switch on or off. According to the logs, the on() and off() functions are triggering at the appropriate times, and the content of those functions are identical to what the base Z-Wave Switch device type uses.

Anyone have pointers on what I’m overlooking? Is this just impossible for some architectural reason? I’m sure I can make it work with a strictly virtual thermostat that links to the physical switch (and temp sensor) via smart app, but I’m trying to keep this as clean of a solution as possible.

Code is as follows. I can provide the code for the temperature link smart app as well, but the static temperature should work fine for diagnosing the issue – just raise/lower setpoint past current temperature to trigger (or not trigger, in my case) the switch on and off.]

import groovy.transform.Field

// enum maps
@Field final Map MODE = [
OFF:   "Off",
AUTO:  "Auto",
]

@Field final Map OP_STATE = [
RUNNING:   "Running",
IDLE:      "Idle"
]

@Field final List RUNNING_OP_STATES = [OP_STATE.RUNNING]
@Field List SUPPORTED_MODES = [MODE.OFF, MODE.AUTO]
@Field final Float    THRESHOLD_DEGREES = 1.0
@Field final Integer  MIN_SETPOINT = 60
@Field final Integer  MAX_SETPOINT = 85
// end config

// derivatives
@Field final IntRange SETPOINT_RANGE = (MIN_SETPOINT..MAX_SETPOINT)

// defaults
@Field final String   DEFAULT_MODE = MODE.AUTO
@Field final String   DEFAULT_OP_STATE = OP_STATE.IDLE
@Field final String   DEFAULT_PREVIOUS_STATE = OP_STATE.RUNNING
@Field final Integer  DEFAULT_TEMPERATURE = 72
@Field final Integer  DEFAULT_THERMOSTAT_SETPOINT = 72

metadata {
	definition (name: "Z-Wave Switch as Thermostat", namespace: "jameslyden", author: "James Lyden", ocfDeviceType: "oic.d.switch") {
		capability "Actuator"
		capability "Indicator"
 		capability "Switch"
		capability "Polling"
		capability "Refresh"
		capability "Sensor"
		capability "Health Check"
		capability "Light"

		// Thermostat additions        
		capability "Thermostat"
		capability "Configuration"
		command "setpointUp"
		command "setpointDown"
		command "cycleMode"
		command "setTemperature", ["number"]

		fingerprint mfr:"0063", prod:"4952", deviceJoinName: "GE Wall Switch"
		fingerprint mfr:"0063", prod:"5257", deviceJoinName: "GE Wall Switch"
		fingerprint mfr:"0063", prod:"5052", deviceJoinName: "GE Plug-In Switch"
		fingerprint mfr:"0113", prod:"5257", deviceJoinName: "Z-Wave Wall Switch"
	}

	// simulator metadata
	simulator {
		status "on":  "command: 2003, payload: FF"
		status "off": "command: 2003, payload: 00"

		// reply messages
		reply "2001FF,delay 100,2502": "command: 2503, payload: FF"
		reply "200100,delay 100,2502": "command: 2503, payload: 00"
	}

	preferences {
		input "ledIndicator", "enum", title: "LED Indicator", description: "Turn LED indicator... ", required: false, options:["on": "When On", "off": "When Off", "never": "Never"], defaultValue: "off"
	}

	// tile definitions
	tiles(scale: 2) {
		standardTile("refresh", "device.switch", width: 2, height: 2, inactiveLabel: false, decoration: "flat") {
			state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh"
		}
    standardTile("mode", "device.thermostatMode", width: 2, height: 2, decoration: "flat") {
        state "Off",            action: "cycleMode", nextState: "Auto", icon: "st.thermostat.heating-cooling-off", backgroundColor: "#CCCCCC"
        state "Auto", label:'Auto',          action: "cycleMode", nextState: "Off", icon: "st.thermostat.heat", defaultState: true
    }
    valueTile("setpoint", "device.thermostatSetpoint", width: 2, height: 2, decoration: "flat") {
        state "Auto", label:'${currentValue} °F', unit: "°F", backgroundColor:"#E86D13"
        state "Off",  label:'Off', backgroundColor:"#CCCCCC"
    }
    valueTile("thermostatMain", "device.thermostatOperatingState", width: 2, height: 2, decoration: "flat") {
        state "Idle", label:'${currentValue}', unit: "°F", backgroundColor:"#00A0DC"
        state "Running", label:'${currentValue}', unit: "°F", backgroundColor:"#E86D13"
		}
    multiAttributeTile(name:"thermostatFull", type:"thermostat", width:6, height:4) {
		    tileAttribute("device.temperature", key: "PRIMARY_CONTROL") {
		    attributeState("temp", label:'${currentValue}', unit:"°F", defaultState: true)
		}
		tileAttribute("device.thermostatSetpoint", key: "VALUE_CONTROL") {
		    attributeState("VALUE_UP", action: "setpointUp")
		    attributeState("VALUE_DOWN", action: "setpointDown")
		}
		tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") {
		    attributeState("Idle", backgroundColor:"#00A0DC")
		    attributeState("Running", backgroundColor:"#e86d13")
		}
		tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") {
		    attributeState("Off", label:'${name}')
		    attributeState("Auto", label:'${name}')
		}
		tileAttribute("device.thermostatSetpoint", key: "HEATING_SETPOINT") {
    		attributeState("thermostatSetpoint", label:'${currentValue}', unit:"dF", defaultState: true)
		}
	}
            
		main "thermostatMain"
		details(["thermostatFull","refresh"])
	}
}

def installed() {
log.trace "Executing 'installed'"
	// Device-Watch simply pings if no device events received for 32min(checkInterval)
	sendEvent(name: "checkInterval", value: 2 * 15 * 60 + 2 * 60, displayed: false, data: [protocol: "zwave", hubHardwareId: device.hub.hardwareID, offlinePingable: "1"])
initialize()
}

def updated(){
		// Device-Watch simply pings if no device events received for 32min(checkInterval)
		sendEvent(name: "checkInterval", value: 2 * 15 * 60 + 2 * 60, displayed: false, data: [protocol: "zwave", hubHardwareId: device.hub.hardwareID, offlinePingable: "1"])
  switch (ledIndicator) {
    case "on":
        indicatorWhenOn()
        break
    case "off":
        indicatorWhenOff()
        break
    case "never":
        indicatorNever()
        break
    default:
        indicatorWhenOn()
        break
}
sendHubCommand(new physicalgraph.device.HubAction(zwave.manufacturerSpecificV1.manufacturerSpecificGet().format()))
}

def getCommandClassVersions() {
	[
		0x20: 1,  // Basic
		0x56: 1,  // Crc16Encap
		0x70: 1,  // Configuration
	]
}

def parse(String description) {
	def result = null
	def cmd = zwave.parse(description, commandClassVersions)
	if (cmd) {
		result = createEvent(zwaveEvent(cmd))
	}
	if (result?.name == 'hail' && hubFirmwareLessThan("000.011.00602")) {
		result = [result, response(zwave.basicV1.basicGet())]
		log.debug "Was hailed: requesting state update"
	} else {
		log.debug "Parse returned ${result?.descriptionText}"
	}
	return result
}

def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicReport cmd) {
	[name: "switch", value: cmd.value ? "on" : "off", type: "physical"]
}

def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet cmd) {
	[name: "switch", value: cmd.value ? "on" : "off", type: "physical"]
}

def zwaveEvent(physicalgraph.zwave.commands.switchbinaryv1.SwitchBinaryReport cmd) {
	[name: "switch", value: cmd.value ? "on" : "off", type: "digital"]
}

def zwaveEvent(physicalgraph.zwave.commands.configurationv1.ConfigurationReport cmd) {
	def value = "when off"
	if (cmd.configurationValue[0] == 1) {value = "when on"}
	if (cmd.configurationValue[0] == 2) {value = "never"}
	[name: "indicatorStatus", value: value, displayed: false]
}

def zwaveEvent(physicalgraph.zwave.commands.hailv1.Hail cmd) {
	[name: "hail", value: "hail", descriptionText: "Switch button was pressed", displayed: false]
}

def zwaveEvent(physicalgraph.zwave.commands.manufacturerspecificv2.ManufacturerSpecificReport cmd) {
	log.debug "manufacturerId:   ${cmd.manufacturerId}"
	log.debug "manufacturerName: ${cmd.manufacturerName}"
	log.debug "productId:        ${cmd.productId}"
	log.debug "productTypeId:    ${cmd.productTypeId}"
	def msr = String.format("%04X-%04X-%04X", cmd.manufacturerId, cmd.productTypeId, cmd.productId)
	updateDataValue("MSR", msr)
	updateDataValue("manufacturer", cmd.manufacturerName)
	createEvent([descriptionText: "$device.displayName MSR: $msr", isStateChange: false])
}

def zwaveEvent(physicalgraph.zwave.commands.crc16encapv1.Crc16Encap cmd) {
	def versions = commandClassVersions
	def version = versions[cmd.commandClass as Integer]
	def ccObj = version ? zwave.commandClass(cmd.commandClass, version) : zwave.commandClass(cmd.commandClass)
	def encapsulatedCommand = ccObj?.command(cmd.command)?.parse(cmd.data)
	if (encapsulatedCommand) {
		zwaveEvent(encapsulatedCommand)
	}
}

def zwaveEvent(physicalgraph.zwave.Command cmd) {
	// Handles all Z-Wave commands we aren't interested in
	[:]
}

def on() {
log.warn "Sending command to turn switch on"
	delayBetween([
		zwave.basicV1.basicSet(value: 0xFF).format(),
		zwave.switchBinaryV1.switchBinaryGet().format()
	])
	log.warn "On command sent"
}

def off() {
log.warn "Sending command to turn switch off"
	delayBetween([
		zwave.basicV1.basicSet(value: 0x00).format(),
		zwave.switchBinaryV1.switchBinaryGet().format()
	])
	log.warn "Off command sent"
}

def poll() {
	delayBetween([
		zwave.switchBinaryV1.switchBinaryGet().format(),
		zwave.manufacturerSpecificV1.manufacturerSpecificGet().format()
	])
}

/**
  * PING is used by Device-Watch in attempt to reach the Device
**/
def ping() {
log.trace "Executing ping"
zwave.switchBinaryV1.switchBinaryGet().format()
sendEvent(name: "thermostatMode", value: getThermostatMode())
sendEvent(name: "thermostatOperatingState", value: getOperatingState())
sendEvent(name: "thermostatSetpoint", value: getThermostatSetpoint(), unit: "°F")
sendEvent(name: "temperature", value: getTemperature(), unit: "°F")
}

def refresh() {
log.trace "Executing refresh"
	delayBetween([
		zwave.switchBinaryV1.switchBinaryGet().format(),
		zwave.manufacturerSpecificV1.manufacturerSpecificGet().format()
	])
sendEvent(name: "thermostatMode", value: getThermostatMode())
sendEvent(name: "thermostatOperatingState", value: getOperatingState())
sendEvent(name: "thermostatSetpoint", value: getThermostatSetpoint(), unit: "°F")
sendEvent(name: "temperature", value: getTemperature(), unit: "°F")
}

void indicatorWhenOn() {
	sendEvent(name: "indicatorStatus", value: "when on", displayed: false)
	sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV1.configurationSet(configurationValue: [1], parameterNumber: 3, size: 1).format()))
}

void indicatorWhenOff() {
	sendEvent(name: "indicatorStatus", value: "when off", displayed: false)
	sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV1.configurationSet(configurationValue: [0], parameterNumber: 3, size: 1).format()))
}

void indicatorNever() {
	sendEvent(name: "indicatorStatus", value: "never", displayed: false)
	sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV1.configurationSet(configurationValue: [2], parameterNumber: 3, size: 1).format()))
}

def invertSwitch(invert=true) {
	if (invert) {
		zwave.configurationV1.configurationSet(configurationValue: [1], parameterNumber: 4, size: 1).format()
	}
	else {
		zwave.configurationV1.configurationSet(configurationValue: [0], parameterNumber: 4, size: 1).format()
	}
}

def configure() {
log.trace "Executing 'configure'"
initialize()
}

private initialize() {
log.trace "Executing 'initialize'"

sendEvent(name: "temperature", value: DEFAULT_TEMPERATURE, unit: "°F")
sendEvent(name: "thermostatSetpointMin", value: SETPOINT_RANGE.getFrom(), unit: "°F")
sendEvent(name: "thermostatSetpointMax", value: SETPOINT_RANGE.getTo(), unit: "°F")
sendEvent(name: "thermostatSetpoint", value: DEFAULT_THERMOSTAT_SETPOINT, unit: "°F")
sendEvent(name: "thermostatMode", value: DEFAULT_MODE)
sendEvent(name: "thermostatOperatingState", value: DEFAULT_OP_STATE)

state.isHvacRunning = device.switch
state.lastOperatingState = DEFAULT_OP_STATE
unschedule()
}

// Thermostat mode
private String getThermostatMode() {
return device.currentValue("thermostatMode") ?: DEFAULT_MODE
}

def setThermostatMode(String value) {
log.trace "Executing 'setThermostatMode' $value"
if (value in SUPPORTED_MODES) {
    proposeSetpoint(getThermostatSetpoint())
    sendEvent(name: "thermostatMode", value: value)
    evaluateOperatingState()
} else {
    log.warn "'$value' is not a supported mode. Please set one of ${SUPPORTED_MODES.join(', ')}"
}
}

private String cycleMode() {
log.trace "Executing 'cycleMode'"
String nextMode = nextListElement(SUPPORTED_MODES, getThermostatMode())
setThermostatMode(nextMode)
return nextMode
}

private Boolean isThermostatOff() {
return getThermostatMode() == MODE.OFF
}

private String nextListElement(List uniqueList, currentElt) {
if (uniqueList != uniqueList.unique().asList()) {
    throw InvalidPararmeterException("Each element of the List argument must be unique.")
} else if (!(currentElt in uniqueList)) {
    throw InvalidParameterException("currentElt '$currentElt' must be a member element in List uniqueList, but was not found.")
}
Integer listIdxMax = uniqueList.size() -1
Integer currentEltIdx = uniqueList.indexOf(currentElt)
Integer nextEltIdx = currentEltIdx < listIdxMax ? ++currentEltIdx : 0
String nextElt = uniqueList[nextEltIdx] as String
return nextElt
}

// operating state
private String getOperatingState() {
String operatingState = device.currentValue("thermostatOperatingState")?:OP_STATE.IDLE
return operatingState
}

private setOperatingState(String operatingState) {
if (operatingState in OP_STATE.values()) {
    sendEvent(name: "thermostatOperatingState", value: operatingState)
    if (operatingState != OP_STATE.IDLE) {
        state.lastOperatingState = operatingState
    }
} else {
    log.warn "'$operatingState' is not a supported operating state. Please set one of ${OP_STATE.values().join(', ')}"
}
}

// setpoint
private Integer getThermostatSetpoint() {
def ts = device.currentState("thermostatSetpoint")
return ts ? ts.getIntegerValue() : DEFAULT_THERMOSTAT_SETPOINT
}

def setThermostatSetpoint(Double degreesF) {
log.trace "Executing 'setThermostatSetpoint' $degreesF"
setThermostatSetpointInternal(degreesF)
}

private setThermostatSetpointInternal(Double degreesF) {
log.debug "setThermostatSetpointInternal($degreesF)"
proposeSetpoint(degreesF as Integer)
evaluateOperatingState(thermostatSetpoint: degreesF)
}

private setpointUp() {
log.trace "Executing 'setpointUp'"
def newTsp = getThermostatSetpoint() + 1
setThermostatSetpoint(newTsp)
}

private setpointDown() {
log.trace "Executing 'setpointDown'"
def newTsp = getThermostatSetpoint() - 1
setThermostatSetpoint(newTsp)
}

// simulated temperature, as set by SmartApp
private Integer getTemperature() {
def ts = device.currentState("temperature")
Integer currentTemp = DEFAULT_TEMPERATURE
try {
    currentTemp = ts.integerValue
} catch (all) {
    log.warn "Encountered an error getting Integer value of temperature state. Value is '$ts.stringValue'. Reverting to default of $DEFAULT_TEMPERATURE"
    setTemperature(DEFAULT_TEMPERATURE)
}
return currentTemp
}

// changes the simulated "room" temperature -- should be getting set by SmartApp
private setTemperature(newTemp) {
sendEvent(name:"temperature", value: newTemp)
evaluateOperatingState(temperature: newTemp)
} 

/**
 * Ensure an integer value is within the provided range, or set it to either extent if it is outside the range.
 * @param Number value         The integer to evaluate
 * @param IntRange theRange     The range within which the value must fall
 * @return Integer
 */
private Integer boundInt(Number value, IntRange theRange) {
value = Math.max(theRange.getFrom(), Math.min(theRange.getTo(), value))
return value.toInteger()
}

private proposeSetpoint(Integer inputSetpoint) {
Integer newSetpoint;
String mode = getThermostatMode()
Integer proposedSetpoint = inputSetpoint?:getThermostatSetpoint()

newSetpoint = boundInt(proposedSetpoint, SETPOINT_RANGE)
if (newSetpoint != proposedSetpoint) {
        log.warn "proposed setpoint $proposedSetpoint is out of bounds. Modifying..."
}

if (newSetpoint != null) {
    log.info "set setpoint of $newSetpoint"
    sendEvent(name: "thermostatSetpoint", value: newSetpoint, unit: "F")
}
}

// compares the thermostat setpoint, current temperature, and operating state and triggers the physical switch as needed.
private evaluateOperatingState(Map overrides) {
// check for override values, otherwise use current state values
Integer currentTemp = overrides.find { key, value -> 
        "$key".toLowerCase().startsWith("curr")|"$key".toLowerCase().startsWith("temp")
    }?.value?:getTemperature() as Integer
Integer currentSetpoint = overrides.find { key, value -> "$key".toLowerCase().startsWith("heat") }?.value?:getThermostatSetpoint() as Integer

String tsMode = getThermostatMode()
String currentOperatingState = getOperatingState()

log.debug "evaluate current temp: $currentTemp, setpoint: $thermostatSetpoint"
log.debug "mode: $tsMode, operating state: $currentOperatingState"

Boolean isRunning = false
if (currentSetpoint - currentTemp >= THRESHOLD_DEGREES) {
log.warn "Turning switch on"
    setOperatingState(OP_STATE.RUNNING)
    isRunning = true
    on()
} else {
log.warn "Turning switch off"
    setOperatingState(OP_STATE.IDLE)
	off()
}
sendEvent(name: "thermostatSetpoint", value: thermostatSetpoint)
}