HomeKit Controller Integration & Hunter fan driver

I might point out that what I did is probably unique to my needs. Depending on temperature we turn this fan on when we go to bed. I currently have it based on time so it only runs for a few hours. My intention is to monitor the temperature in the room and maybe slow the fan down at some point and off later depending on what the temperature is. There are nights when I end up getting up and turning the fan back on after it shut off and other times I get up and turn it off before the time times out. All of this usually only occurs during the spring and fall when temperatures flucuate a lot. During those times we try to avoid using the furnace of the AC. Not always successful, but.... Another reason for the speed control at the switch is my wife can't reach the pull chain.

i developed a apps code from scratch and with the help of Gemini this time, i was able to make it work. I am testing it right now and if it works, will post the code in few days.

For my usage the app I made does everything I want it to. I added in the temperature control and it works also. Got to play with the temps some, but the basics are there.

Having a dimmer doesn't seem to me to add much as mine just has the 4 speeds anyway. Setting the dimmer to anything else just causes a rounding to the nearest speed.

Good luck with your app. Hope it does what you want.

You get your app working?

Yes it did perfectly.

Here is the one i am using on my living room fan with 3 speed controls

/**
 * Fan Sync with Virtual Dimmer - 3 Speed
 *
 * Copyright 2026 ajeevlal
 * Use at your own risk 
 */

definition(
    name: "Fan Sync - 3 Speed",
    namespace: "ajeevlal",
    author: "ajeevlal",
    description: "High-reliability sync with timestamp guarding to prevent command loss.",
    category: "Convenience",
    iconUrl: "",
    iconX2Url: ""
)

preferences {
    section("Devices") {
        input "virtualDimmer", "capability.switchLevel", title: "Virtual Fan Dimmer", required: true
        input "physicalFan", "capability.switchLevel", title: "Physical Fan Device", required: true
    }
}

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

def initialize() {
    subscribe(virtualDimmer, "switch", virtualHandler)
    subscribe(virtualDimmer, "level", virtualHandler)
    subscribe(physicalFan, "switch", physicalHandler)
    subscribe(physicalFan, "level", physicalHandler)
    state.lastLevel = state.lastLevel ?: 33
    state.lastCommandTime = 0
}

// Helper to check if we should ignore an event (within 2 seconds of a command)
def isIgnoring() {
    return (now() - (state.lastCommandTime ?: 0) < 2000)
}

def virtualHandler(evt) {
    if (isIgnoring()) {
        log.debug "Ignoring reflected virtual event: ${evt.name}"
        return
    }

    if (evt.name == "switch") {
        state.lastCommandTime = now() // Set the guard immediately
        if (evt.value == "off") {
            log.info "OFF Sequence: Parking at 10, UI to 0"
            physicalFan.setLevel(10)
            pauseExecution(1000)
            physicalFan.off()
            virtualDimmer.setLevel(0)
        } else {
            int restoreTo = (state.lastLevel > 15) ? state.lastLevel : 33
            log.info "ON Sequence: Restoring to ${restoreTo}%"
            physicalFan.setLevel(restoreTo)
            physicalFan.on()
            virtualDimmer.setLevel(restoreTo)
        }
    }

    if (evt.name == "level") {
        int level = evt.value.toInteger()
        if (level == 0) {
            virtualDimmer.off()
            return
        }

        // Snap to clean levels
        int target = 33
        if (level > 45 && level <= 77) target = 67
        else if (level > 77) target = 99
        
        if (physicalFan.currentLevel?.toInteger() != target) {
            log.info "Syncing Level to ${target}%"
            state.lastCommandTime = now()
            state.lastLevel = target
            physicalFan.setLevel(target)
            // Sync virtual UI to the 'snapped' value
            virtualDimmer.setLevel(target)
        }
    }
}

def physicalHandler(evt) {
    if (isIgnoring()) {
        log.debug "Ignoring physical chatter: ${evt.name}"
        return
    }

    if (evt.name == "level") {
        int level = evt.value.toInteger()
        if (level > 15) { 
            state.lastLevel = level
            virtualDimmer.setLevel(level)
        }
    }
    
    if (evt.name == "switch") {
        if (evt.value == "off") {
            virtualDimmer.setLevel(0)
            virtualDimmer.off()
        } else {
            // If physical turned ON, match virtual to physical's current state
            virtualDimmer.on()
            int pLevel = physicalFan.currentLevel?.toInteger() ?: 33
            virtualDimmer.setLevel(pLevel)
        }
    }
}

and here is the one with 4 speed that i use on other fans

/**
 * Fan Sync with Virtual Dimmer - 4 Speed
 *
 * Copyright 2026 ajeevlal
 * Use at your own risk 
 */


definition(
    name: "Fan Sync - 4 Speed",
    namespace: "ajeevlal",
    author: "ajeevlal",
    description: "High-reliability sync with 4-speed logic (25, 50, 75, 100) and timestamp guarding.",
    category: "Convenience",
    iconUrl: "",
    iconX2Url: ""
)

preferences {
    section("Devices") {
        input "virtualDimmer", "capability.switchLevel", title: "Virtual Fan Dimmer", required: true
        input "physicalFan", "capability.switchLevel", title: "Physical Fan Device", required: true
    }
}

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

def initialize() {
    subscribe(virtualDimmer, "switch", virtualHandler)
    subscribe(virtualDimmer, "level", virtualHandler)
    subscribe(physicalFan, "switch", physicalHandler)
    subscribe(physicalFan, "level", physicalHandler)
    state.lastLevel = state.lastLevel ?: 25
    state.lastCommandTime = 0
}

def isIgnoring() {
    return (now() - (state.lastCommandTime ?: 0) < 2000)
}

def virtualHandler(evt) {
    if (isIgnoring()) {
        log.debug "Ignoring reflected virtual event: ${evt.name}"
        return
    }

    if (evt.name == "switch") {
        state.lastCommandTime = now() 
        if (evt.value == "off") {
            log.info "OFF Sequence: Parking at 10, UI to 0"
            physicalFan.setLevel(10)
            pauseExecution(1000)
            physicalFan.off()
            virtualDimmer.setLevel(0)
        } else {
            // Restore to last level, or default to 25% (Speed 1)
            int restoreTo = (state.lastLevel > 15) ? state.lastLevel : 25
            log.info "ON Sequence: Restoring to ${restoreTo}%"
            physicalFan.setLevel(restoreTo)
            physicalFan.on()
            virtualDimmer.setLevel(restoreTo)
        }
    }

    if (evt.name == "level") {
        int level = evt.value.toInteger()
        if (level == 0) {
            virtualDimmer.off()
            return
        }

        // Logic for 4-Speed Snapping (25, 50, 75, 100)
        int target = 25
        if (level > 37 && level <= 62) target = 50
        else if (level > 62 && level <= 87) target = 75
        else if (level > 87) target = 100
        
        if (physicalFan.currentLevel?.toInteger() != target) {
            log.info "Syncing 4-Speed Level to ${target}%"
            state.lastCommandTime = now()
            state.lastLevel = target
            physicalFan.setLevel(target)
            virtualDimmer.setLevel(target)
        }
    }
}

def physicalHandler(evt) {
    if (isIgnoring()) {
        log.debug "Ignoring physical chatter: ${evt.name}"
        return
    }

    if (evt.name == "level") {
        int level = evt.value.toInteger()
        // If it's above the 10% "park" level, sync it to the UI
        if (level > 15) { 
            state.lastLevel = level
            virtualDimmer.setLevel(level)
        }
    }
    
    if (evt.name == "switch") {
        if (evt.value == "off") {
            virtualDimmer.setLevel(0)
            virtualDimmer.off()
        } else {
            virtualDimmer.on()
            int pLevel = physicalFan.currentLevel?.toInteger() ?: 25
            virtualDimmer.setLevel(pLevel)
        }
    }
}
1 Like