Turn down the air conditioning for a while before sunset if there's a lot of excess solar (WIP)

EDIT if you are interested in this, please get the latest code from GitHub:

I've an Emporia energy monitor for my electrical system and solar, and thanks to @ke7lvb I have a driver for it. [RELEASE] Emporia Vue V2 driver for Hubitat

Now the Emporia has a system to use "Excess Solar" to charge my car which works well. Emporia is also supposed to have as system to use "Excess Solar" in conjunction with my ecobee thermostat, but it's very confusing, not well documented, not configurable, and seems tied with time-of-use energy pricing which I don't have. I tried to set it up and it bumped up the heat intolerably yesterday when the sun was shining, which didn't make me popular with SWMBO.

I decided to ask my buddy ChatGPT to write something simpler.

"I want a hubitat app that T hours before sunset, if I'm sending more than E kw of power from my solar back to the grid, lowers the temperature on my ecobee thermostat by Temp degrees. (But only if it's in air conditioning mode). When the excess solar drops below E2, it turns the temperature back up to what it was, but only if the temperature setpoint hasn't already been changed (manually, or by some other process)"

She gave me something that looked useful. Then I asked for a refinement:

"Let's give a configuration option of celcius (vs fahrenheit). Let's also deal with the situation where there's clouds at startmonitoring so the power threshold isn't reached at that time, by continuing to check every TD minutes (default 15 minutes) until sunset, if it doesn't get lowered at the first check."

The app she came up with is below. I can't really test it since it's not air conditioning season quite yet here. But, I think it looks good. If you're interested in this, can you have a glance at the code and let me know what you think? I'll post a follow up when I have a chance to test it, and get it into github.

I haven't customized the code at all yet, this version is pure ChatGPT's o4-mini-high model. Here's a link to the chat:

/**
 *  Solar-Driven AC Setback (with Celsius & re-checks)
 *
 *  Author:  Your Name
 *  Date:    2025-04-28
 *
 *  Lowers Ecobee cooling setpoint by X° when solar export > E kW
 *  starting T hours before sunset, and restores it when export < E2.
 *  Includes option for Celsius vs Fahrenheit and will re-check
 *  every TD minutes until sunset if export threshold isn't hit initially.
 */

definition(
    name: "Solar-Driven AC Setback",
    namespace: "yournamespace",
    author: "Your Name",
    description: "Drop Ecobee coolingSetpoint by Temp° when solar export > E between sunsetāˆ’T and sunrise, with C/F option & repeated checks.",
    category: "Green Energy",
    iconUrl: "",
    iconX2Url: ""
)

preferences {
    section("Select your devices") {
        input "solarMeter", "capability.powerMeter",
            title: "Solar Power Meter (reports watts)", required: true
        input "thermostat", "capability.thermostat",
            title: "Ecobee Thermostat", required: true
    }
    section("Thresholds & offsets") {
        input "offsetHours", "decimal",
            title: "T hours before sunset to start watching", defaultValue: 2.0, required: true
        input "thresholdHigh", "decimal",
            title: "High threshold E (kW export)", defaultValue: 1.0, required: true
        input "thresholdLow", "decimal",
            title: "Low threshold E2 (kW export)", defaultValue: 0.5, required: true
        input "tempChange", "decimal",
            title: "Drop cooling setpoint by (°)", defaultValue: 2.0, required: true
    }
    section("Options") {
        input "useCelsius", "bool",
            title: "Use Celsius (uncheck for Fahrenheit)", defaultValue: false
        input "checkInterval", "number",
            title: "Re-check interval TD (minutes) until sunset", defaultValue: 15, required: true
    }
}

def installed() { initialize() }
def updated()  {
    unsubscribe()
    unschedule()
    initialize()
}

private initialize() {
    state.lowered          = false
    state.monitoring       = false
    state.originalSetpoint = null
    state.loweredSetpoint  = null
    state.sunsetTime       = null

    subscribe(solarMeter, "power",        onPowerEvent)
    subscribe(thermostat, "thermostatMode", onThermostatMode)
    subscribe(location,  "sunriseTime",   resetDaily)

    scheduleDailyOffset()
}

def scheduleDailyOffset() {
    def sun    = getSunriseAndSunset()
    def sunset = sun.sunset
    state.sunsetTime = sunset.time
    def offsetMs = (offsetHours.toDouble() * 3600 * 1000) as long
    def runTime  = new Date(sunset.time - offsetMs)
    log.debug "Scheduling startMonitoring at ${runTime}"
    schedule(runTime, "startMonitoring")
}

def startMonitoring() {
    state.monitoring = true
    log.info "Starting solar-driven setback monitoring at ${new Date()} until sunset (${new Date(state.sunsetTime)})"
    // immediate check
    def currentPower = (solarMeter.currentValue("power") as Number)?.toDouble() ?: 0.0
    handlePower(currentPower)
    // if not lowered yet, schedule re-checks
    if (!state.lowered) {
        scheduleNextCheck()
    }
}

private scheduleNextCheck() {
    def now = new Date().time
    if (!state.lowered && now < state.sunsetTime) {
        log.debug "Scheduling next threshold check in ${checkInterval} minutes"
        runIn(checkInterval.toInteger() * 60, "thresholdCheck")
    }
}

def thresholdCheck() {
    if (!state.monitoring) return
    def power = (solarMeter.currentValue("power") as Number)?.toDouble() ?: 0.0
    handlePower(power)
    if (!state.lowered) {
        scheduleNextCheck()
    }
}

def onPowerEvent(evt) {
    if (!state.monitoring) return
    handlePower((evt.value as Number).toDouble())
}

private handlePower(powerWatts) {
    def highW = thresholdHigh.toDouble() * 1000
    def lowW  = thresholdLow.toDouble()  * 1000
    def mode  = thermostat.currentThermostatMode

    if (!state.lowered && mode == "cool" && powerWatts > highW) {
        lowerSetpoint()
    }
    else if (state.lowered && powerWatts < lowW) {
        restoreSetpoint()
    }
}

def onThermostatMode(evt) {
    if (state.monitoring && !state.lowered && evt.value == "cool") {
        def p = (solarMeter.currentValue("power") as Number)?.toDouble() ?: 0.0
        handlePower(p)
    }
}

private lowerSetpoint() {
    def curr = (thermostat.currentValue("coolingSetpoint") as Number).toDouble()
    state.originalSetpoint = curr
    def newSet = (curr - tempChange.toDouble()).round(1)
    thermostat.setCoolingSetpoint(newSet)
    state.loweredSetpoint = newSet
    state.lowered         = true
    log.info "Lowered cooling setpoint from ${state.originalSetpoint}${unit()} to ${newSet}${unit()}."
}

private restoreSetpoint() {
    def curr = (thermostat.currentValue("coolingSetpoint") as Number).toDouble()
    if (curr != state.loweredSetpoint) {
        log.warn "Setpoint was changed manually (is ${curr}${unit()}), not restoring."
    } else {
        thermostat.setCoolingSetpoint(state.originalSetpoint)
        log.info "Restored cooling setpoint to ${state.originalSetpoint}${unit()}."
        state.lowered = false
    }
    state.monitoring = false
}

def resetDaily(evt) {
    log.debug "Sunrise—resetting daily state."
    state.lowered          = false
    state.monitoring       = false
    state.originalSetpoint = null
    state.loweredSetpoint  = null
    state.sunsetTime       = null
    scheduleDailyOffset()
}

private String unit() {
    useCelsius ? "°C" : "°F"
}

A few more tweaks. Power export is a negative value in the Vue driver, so set that up as an option. Round Celcius set points to 0.5 degrees. The previous version would restore the cooling setpoint for the day if a cloud obstructed the sun, this version also restores the setpoint but can lower the setpoint again if the cloud goes away. This version has logging statements. It also (edit: optionally) responds in the case the thermostat is in "auto" mode. Edit: also some bug fixes related to the fact that event values are always strings.

/**
 *  Solar-Driven AC Setback (with manual-override, sunset restore & power-invert)
 *
 *  Author:  John Abraham
 *  Date:    2025-04-28
 *
 *  Continuously adjust thermostat cooling setpoint by Δ° whenever
 *  solar export crosses E/E2 kW, from T hours before sunset through sunset.
 *  Stops monitoring if you manually change the setpoint.  
 *  Restores at sunset if still lowered (unless manually overridden).  
 *  Supports Celsius/°F & optional power-value inversion.
 *  Now you can also choose whether it applies in AUTO mode.
 *
 *  All props to ChatGPT.  This code has not yet been tested. 
 */

definition(
    name: "Solar-Driven AC Setback",
    namespace: "jeabraham",
    author: "John Abraham",
    description: "Continuously adjust setpoint by Temp° when solar export > E / < E2, from sunset-T until sunset, with manual-override stop, sunset-restore, C/F, invert-power & optional auto-mode support.",
    category: "Green Energy",
    iconUrl:   "",
    iconX2Url: ""
)

preferences {
    section("Select your devices") {
        input "solarMeter",    "capability.powerMeter",
            title: "Solar Power Meter (W)", required: true
        input "thermostat",    "capability.thermostat",
            title: "Thermostat (must support coolingSetpoint)", required: true
    }
    section("Thresholds & offsets") {
        input "offsetHours",    "decimal",
            title: "T hours before sunset",        defaultValue: 2.0, required: true
        input "thresholdHigh", "decimal",
            title: "High threshold E (kW export)", defaultValue: 1.0, required: true
        input "thresholdLow",  "decimal",
            title: "Low threshold E2 (kW export)", defaultValue: 0.5, required: true
        input "tempChange",    "decimal",
            title: "Ī” setpoint (°)",               defaultValue: 2.0, required: true
    }
    section("Options") {
        input "useCelsius",    "bool",
            title: "Use Celsius (uncheck for °F)", defaultValue: true
        input "invertPower",   "bool",
            title: "Invert power sign (if export is negative)", defaultValue: false
        input "applyToAuto",   "bool",
            title: "Apply setback in AUTO mode (unchecked: only in COOL)", defaultValue: false
        input "checkInterval","number",
            title: "Re-check every TD minutes until sunset", defaultValue: 15, required: true
    }
}

def installed() { 
    validateSettings()
    initialize() 
}

def updated()  {
    unsubscribe()
    unschedule()
    validateSettings()
    initialize()
}

private void validateSettings() {
    def high = thresholdHigh.toDouble()
    def low  = thresholdLow.toDouble()
    if (high - low < 0.5) {
        log.error "${app.label}: Validation failed—Low threshold (E2) must be at least 0.5 kW below High threshold (E)."
        throw new IllegalArgumentException("āŒ Low threshold E2 must be at least 0.5 kW lower than High threshold E.")
    }
}

private initialize() {
    log.debug "initialize(): resetting state & subscriptions"
    state.lowered          = false
    state.monitoring       = false
    state.originalSetpoint = null
    state.loweredSetpoint  = null
    state.sunsetTime       = null

    subscribe(solarMeter,      "power",           onPowerEvent)
    subscribe(thermostat,      "thermostatMode",  onThermostatMode)
    subscribe(thermostat,      "coolingSetpoint", onSetpointChange)
    subscribe(location,        "sunriseTime",     resetDaily)

    scheduleDailyJobs()
}

private scheduleDailyJobs() {
    def sun    = getSunriseAndSunset()
    def sunset = sun.sunset
    state.sunsetTime = sunset.time
    log.debug "Scheduling startMonitoring and stopMonitoring around sunset at ${sunset}"

    // 1) start monitoring at sunset āˆ’ offsetHours
    def startAt  = new Date(sunset.time - (offsetHours.toDouble() * 3600*1000).toLong())
    if (startAt.time <= now()) {
        log.warn "Sunsetāˆ’T is already past; starting monitoring immediately"
        startMonitoring()
    } else {
        schedule(startAt, "startMonitoring")
        log.debug "Scheduled startMonitoring() at ${startAt}"
    }

    // 2) stop monitoring (and restore if needed) at actual sunset
    schedule(sunset, "stopMonitoring")
    log.debug "Scheduled stopMonitoring() at ${sunset}"
}

def startMonitoring() {
    state.monitoring = true
    log.info  "ā–¶ļøŽ startMonitoring(): ON from ${new Date()} until sunset (${new Date(state.sunsetTime)})"
    thresholdCheck()  // initial check + schedule repeats
}

def stopMonitoring() {
    log.info  "ā–  stopMonitoring(): OFF at sunset (${new Date()})"
    state.monitoring = false
    unschedule("thresholdCheck")

    if (state.lowered) {
        def curr = thermostat.currentValue("coolingSetpoint").toString().toDouble()
        if (curr == state.loweredSetpoint) {
            log.info "ā— Sunset restore: restoring original setpoint of ${state.originalSetpoint}${unit()}"
            restoreSetpoint()
        } else {
            log.warn "⚠ Sunset restore skipped: detected manual override (${curr}${unit()})"
        }
    }
}

def onPowerEvent(evt) {
    if (state.monitoring) {
        handlePower(evt.value.toString().toDouble())
    }
}

def thresholdCheck() {
    if (!state.monitoring) {
        log.trace "thresholdCheck(): skipped because monitoring=false"
        return
    }
    def rawPower = solarMeter.currentValue("power").toString().toDouble()
    handlePower(rawPower)

    if (now() < state.sunsetTime) {
        log.debug "thresholdCheck(): scheduling next in ${checkInterval} min"
        runIn(checkInterval.toInteger() * 60, "thresholdCheck")
    } else {
        log.debug "thresholdCheck(): past sunset, no more checks"
    }
}

private handlePower(rawPower) {
    log.trace "handlePower(): rawPower=${rawPower} W"
    def power = invertPower ? -rawPower : rawPower
    if (invertPower) {
        log.debug "handlePower(): inverted power=${power} W"
    }

    double highW = thresholdHigh.toString().toDouble() * 1000
    double lowW  = thresholdLow.toString().toDouble()  * 1000
    log.trace "handlePower(): comparing ${power} W to high>${highW} and low<${lowW}"

    def mode = thermostat.currentThermostatMode

    // If not yet lowered & export > high threshold:
    if (!state.lowered && power > highW) {
        if (mode == "cool") {
            log.info  "handlePower(): mode=COOL & export ${power} W > ${highW} W → lowering setpoint"
            lowerSetpoint()
        }
        else if (mode == "auto" && applyToAuto) {
            log.info  "handlePower(): mode=AUTO & applyToAuto → lowering setpoint"
            lowerSetpoint()
        }
        else {
            log.debug "handlePower(): export > high, but mode=${mode}, applyToAuto=${applyToAuto}"
        }
    }
    // If already lowered & export < low threshold → restore
    else if (state.lowered && power < lowW) {
        log.info  "handlePower(): export ${power} W < ${lowW} W → restoring setpoint"
        restoreSetpoint()
    }
    else {
        log.debug "handlePower(): no action. lowered=${state.lowered}, mode=${mode}"
    }
}

def onThermostatMode(evt) {
    log.debug "onThermostatMode(): mode changed to ${evt.value}"
    if (state.monitoring && !state.lowered && evt.value == "cool") {
        log.trace "onThermostatMode(): mode→COOL, re-checking export"
        thresholdCheck()
    }
}

def onSetpointChange(evt) {
    def v = evt.value.toString().toDouble()
    // ignore our own lower/restore events
    if (state.lowered && v == state.loweredSetpoint ||
        !state.lowered && state.originalSetpoint != null && v == state.originalSetpoint) {
        log.trace "onSetpointChange(): programmatic change to ${v}${unit()}, ignoring"
        return
    }
    log.warn  "onSetpointChange(): detected manual setpoint change to ${evt.value}${unit()}"
    if (state.monitoring) {
        log.warn  "onSetpointChange(): stopping monitoring for today due to manual override"
        state.monitoring = false
        unschedule("thresholdCheck")
    }
}

private lowerSetpoint() {
    def curr   = thermostat.currentValue("coolingSetpoint").toString().toDouble()
    state.originalSetpoint = curr
    def rawNew = curr - tempChange.toDouble()
    // if Celsius, round to nearest 0.5; else one-decimal
    def newSet = useCelsius
        ? (Math.round(rawNew * 2) / 2.0)
        : rawNew.round(1)
    thermostat.setCoolingSetpoint(newSet)
    state.loweredSetpoint  = newSet
    state.lowered          = true
    log.info  "⬇ lowerSetpoint(): from ${curr}${unit()} → ${newSet}${unit()}"
}

private restoreSetpoint() {
    try {
        def curr = thermostat.currentValue("coolingSetpoint").toString().toDouble()
        if (curr != state.loweredSetpoint) {
            log.warn "restoreSetpoint(): manual override detected (${curr}${unit()}), skip restore"
        } else {
            thermostat.setCoolingSetpoint(state.originalSetpoint)
            log.info "⬆ restoreSetpoint(): back to ${state.originalSetpoint}${unit()}"
            state.lowered = false
        }
    } catch (e) {
        log.error "restoreSetpoint(): unexpected error: ${e}"
    }
}

def resetDaily(evt) {
    log.debug "resetDaily(): sunrise—clearing state for new day"
    unsubscribe()
    unschedule()
    initialize()
}

private String unit() {
    useCelsius ? "°C" : "°F"
}

Finally added it to GitHub, you can review the commit history there. There's also a nice README.