Levoit Air Purifiers Drivers

I hope your wiring project went well. Did you ever get around to building a driver for the 131S?

2 Likes

Crap, has it really been three months?? Looks like I am a good procrastinator. I didn’t forget, it just hasn’t bubbled up to the top of the list. I check it out by the end of this week. And yes the wiring project was straight forward, thanks for asking.

For Christmas I got a new iotawatt system. That’s still on the list as well. Geesh.

Thanks Nick, I'll see what I can come up with.

Ok, i finally spent some time on this.

I should say that building a driver is probably outside my skill set, but i have been playing.

I will continue tomorrow.

Played with it twice more this week. I didn’t get past where I was.

At what step are you seeing this? Do you have your code on GitHub? If so, I can take a look.

I suggest using Postman to manually explore the API -- that's what I've been doing. The login and listing of devices should be the same, anyway, it's just when you access the device itself that the code should be deviating.

I modified your parent driver to include my model. Then I created a new child driver based of your 400 model. The I created a virtual device using the parent driver, put in my credential, get an account ID and token and hit resync.

It properly selects the correct child driver but I have a data mismatch (off course) and I don’t see any feedback to help me resolve this. I am not a SW guy so I am not even going to attempt to use postman (I did google it) to debug a cloud API. Definitely above my intelligence level.

I appreciate your offer of help though.

Thanks for building this code! Very useful!

My 600S is recognized as "LAP-C601S-WUS" - from there I was able to modify your code so as to have it work with my 600S. Also, for some reason, I had to add a "max" speed parameter corresponding to level 4, in the device driver.

I would be willing to donate to have drivers for the 131s

Hi, any chance you would be willing to share your modified code for the 600S with me. Thanks.

1 Like

Sure! There it is. It's messy, though, had little time to implement it, but works.

/* 
MIT License
Copyright (c) Niklas Gustafsson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

// History:
// 
// 2021-10-22: v1.0 Support for Levoit Air Purifier Core 200S / 400S


metadata {
    definition(
        name: "Levoit Core400S & 600S Air Purifier",
        namespace: "NiklasGustafsson",
        author: "Niklas Gustafsson and elfege (contributor)",
        description: "Supports controlling the Levoit 400S air purifier",
        category: "My Apps",
        iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png",
        iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png",
        iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png",
        documentationLink: "https://github.com/dcmeglio/hubitat-bond/blob/master/README.md")
    {
        capability "Switch"
        capability "FanControl"
        capability "Actuator"
        capability "SwitchLevel"

        attribute "filter", "number";                              // Filter status (0-100%)
        attribute "mode", "string";                                // Purifier mode 

        attribute "aqi", "number";                                 // AQI (0-500)
        attribute "aqiDanger", "string";                           // AQI danger level
        attribute "aqiColor", "string";                            // AQI HTML color

        attribute "info", "string";                               // HTML

        command "setDisplay", [[name:"Display*", type: "ENUM", description: "Display", constraints: ["on", "off"] ] ]
        command "setSpeed", [[name:"Speed*", type: "ENUM", description: "Speed", constraints: ["off", "sleep", "auto", "low", "medium", "high", "max"] ] ]
        command "setMode",  [[name:"Mode*", type: "ENUM", description: "Mode", constraints: ["manual", "sleep", "auto"] ] ]
        command "toggle"
    }

    preferences {
        input("debugOutput", "bool", title: "Enable debug logging?", defaultValue: false, required: false)
    }
}

def installed() {
    logDebug "Installed with settings: ${settings}"
    updated();
}

def updated() {
    logDebug "Updated with settings: ${settings}"
    state.clear()
    unschedule()
    initialize()

    runIn(3, update)

    // Turn off debug log in 30 minutes
    if (settings?.debugOutput) runIn(1800, logDebugOff);
}

def parse(String description) // no parse in virtual device
{
    log.trace "Msg: Description is $description"
    
}
def uninstalled() {
    logDebug "Uninstalled app"
}

def initialize() {
    logDebug "initializing"
}

def on() {
    logDebug "on()"
    handlePower(true)
    handleEvent("switch", "on")

    if (state.speed != null) {
        setSpeed(state.speed)
    }
    else {
        setSpeed("low")
    }

    if (state.mode != null) {
        setMode(state.mode)
    }
    else {
        update()
    }
}

def off() {
    logDebug "off()"
    handlePower(false)
    handleEvent("switch", "off")
    handleEvent("speed", "off")
}

def toggle() {
    logDebug "toggle()"
    if (device.currentValue("switch") == "on")
    {
        off()
    }
    else
    {
        on()
    }
}


def cycleSpeed() {
    logDebug "cycleSpeed()"
    def speed = (state.speed == "low") ? "medium" : ( (state.speed == "medium") ? "high" : "low")

    if (state.switch == "off")
    {
        on()
    }
    setSpeed(speed)
}

def setLevel(value)
{
    logDebug "setLevel $value"
    def speed = 0
    setMode("manual") // always manual if setLevel() cmd was called

    if(value < 25) speed = 1
    if(value >= 25 && value < 50) speed = 2
    if(value >= 50 && value < 75) speed = 3
    if(value >= 75) speed = 4
    
    
    sendEvent(name: "level", value: value)
    setSpeed(speed)
}

def setSpeed(speed) {
    logDebug "setSpeed(${speed})"
    if (speed == "off") {
        off()
    }
    else if (speed == "auto") {
        setMode(speed)
        state.speed = speed
        handleEvent("speed", speed)
    }
    else if (speed == "sleep") {
        setMode(speed)
        handleEvent("speed", "on")
    }
    else if (state.mode == "manual") {
        handleSpeed(speed)
        state.speed = speed
        handleEvent("speed", speed)
    }
    else if (state.mode == "sleep") {
        setMode("manual")
        handleSpeed(speed)
        state.speed = speed
        device.sendEvent(name: "speed", value: speed)
    }
}

def setMode(mode) {
    logDebug "setMode(${mode})"

    handleMode(mode)
    state.mode = mode
    handleEvent("mode", mode)

    switch(mode)
    {
        case "manual":
        handleEvent("speed",  state.speed)
        break;
        case "auto":
        handleEvent("speed",  "auto")
        break;
        case "sleep":
        handleEvent("speed",  "on")
        break;
    }
}

def setDisplay(displayOn) {
    logDebug "setDisplay(${displayOn})"
    handleDisplayOn(displayOn)
}

def mapSpeedToInteger(speed) {

    switch(speed)
    {
        case "1":
        return 1
        case "low":
        return 1
        case "2":
        return 2
        case "medium":
        return 2
        case "3":
        return 3
        case "high":
        return 3
        case "4":
        return 4
        case "max":
        return 4


    }
    return (speed == "low") ? 1 : ( (speed == "medium") ? 2 : 3)
}

def mapIntegerStringToSpeed(speed) {
    return (speed == "1") ? "low" : ( (speed == "2") ? "medium" : "high")
}

def mapIntegerToSpeed(speed) {
    return (speed == 1) ? "low" : ( (speed == 2) ? "medium" : "high")
}

def logDebug(msg) {
    if (settings?.debugOutput) {
        log.debug msg
    }
}

def logError(msg) {
    log.error msg
}

void logDebugOff() {
    //
    // runIn() callback to disable "Debug" logging after 30 minutes
    // Cannot be private
    //
    if (settings?.debugOutput) device.updateSetting("debugOutput", [type: "bool", value: false]);
}

def handlePower(on) {

    def result = false

    parent.sendBypassRequest(device, [
        data: [ enabled: on, id: 0 ],
        "method": "setSwitch",
        "source": "APP" ]) { resp ->
        if (checkHttpResponse("handleOn", resp))
        {
            def operation = on ? "ON" : "OFF"
            logDebug "turned ${operation}()"
            result = true
        }
    }
    return result
}

def handleSpeed(speed) {

    def result = false

    parent.sendBypassRequest(device, [
        data: [ level: mapSpeedToInteger(speed), id: 0, type: "wind" ],
        "method": "setLevel",
        "source": "APP"
    ]) { resp ->
        if (checkHttpResponse("handleSpeed", resp))
        {
            logDebug "Set speed"
            result = true
        }
    }
    return result
}

def handleMode(mode) {

    def result = false

    parent.sendBypassRequest(device, [
        data: [ "mode": mode ],
        "method": "setPurifierMode",
        "source": "APP"
    ]) { resp ->
        if (checkHttpResponse("handleMode", resp))
        {
            logDebug "Set mode"
            result = true
        }
    }
    return result
}

def update() {

    logDebug "update()"

    def result = null

    parent.sendBypassRequest(device,  [
        "method": "getPurifierStatus",
        "source": "APP"
    ]) { resp ->
        if (checkHttpResponse("update", resp))
        {
            def status = resp.data.result
            result = update(status, null)                
        }
    }
    return result
}

def update(status, nightLight){
    logDebug "update(status, nightLight)"

    logDebug status

    state.speed = mapIntegerToSpeed(status.result.level)
    state.mode = status.result.mode

    handleEvent("switch", status.result.enabled ? "on" : "off")
    handleEvent("mode",   status.result.mode)
    handleEvent("filter", status.result.filter_life)

    switch(state.mode)
    {
        case "manual":
        handleEvent("speed",  mapIntegerToSpeed(status.result.level))
        break;
        case "auto":
        handleEvent("speed",  "auto")
        break;
        case "sleep":
        handleEvent("speed",  "on")
        break;
    }

    updateAQIandFilter(status.result.air_quality_value.toString(),status.result.filter_life)
}

private void handleEvent(name, val){
    logDebug "handleEvent(${name}, ${val})"
    device.sendEvent(name: name, value: val)
}

private void updateAQIandFilter(String val, filter) {

    logDebug "updateAQI(${val})"

    //
    // Conversions based on https://en.wikipedia.org/wiki/Air_quality_index
    //
    BigDecimal pm = val.toBigDecimal();

    BigDecimal aqi;

    if (state.prevPM == null || state.prevPM != pm || state.prevFilter == null || state.prevFilter != filter) {

        state.prevPM = pm;
        state.prevFilter = filter;

        if      (pm <  12.1) aqi = convertRange(pm,   0.0,  12.0,   0,  50);
        else if (pm <  35.5) aqi = convertRange(pm,  12.1,  35.4,  51, 100);
            else if (pm <  55.5) aqi = convertRange(pm,  35.5,  55.4, 101, 150);
                else if (pm < 150.5) aqi = convertRange(pm,  55.5, 150.4, 151, 200);
                    else if (pm < 250.5) aqi = convertRange(pm, 150.5, 250.4, 201, 300);
                        else if (pm < 350.5) aqi = convertRange(pm, 250.5, 350.4, 301, 400);
                            else                 aqi = convertRange(pm, 350.5, 500.4, 401, 500);

                            handleEvent("AQI", aqi);

                        String danger;
                    String color;

                if      (aqi <  51) { danger = "Good";                           color = "7e0023"; }
        else if (aqi < 101) { danger = "Moderate";                       color = "fff300"; }
        else if (aqi < 151) { danger = "Unhealthy for Sensitive Groups"; color = "f18b00"; }
        else if (aqi < 201) { danger = "Unhealthy";                      color = "e53210"; }
        else if (aqi < 301) { danger = "Very Unhealthy";                 color = "b567a4"; }
        else if (aqi < 401) { danger = "Hazardous";                      color = "7e0023"; }
        else {                danger = "Hazardous";                      color = "7e0023"; }

        handleEvent("aqiColor", color)
        handleEvent("aqiDanger", danger)

        def html = "AQI: ${aqi}<br>PM2.5: ${pm} &micro;g/m&sup3;<br>Filter: ${filter}%"

        handleEvent("info", html)
    }
}

private BigDecimal convertRange(BigDecimal val, BigDecimal inMin, BigDecimal inMax, BigDecimal outMin, BigDecimal outMax, Boolean returnInt = true) {
    // Let make sure ranges are correct
    assert (inMin <= inMax);
    assert (outMin <= outMax);

    // Restrain input value
    if (val < inMin) val = inMin;
    else if (val > inMax) val = inMax;

        val = ((val - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
    if (returnInt) {
        // If integer is required we use the Float round because the BigDecimal one is not supported/not working on Hubitat
        val = val.toFloat().round().toBigDecimal();
    }

    return (val);
}

def handleDisplayOn(displayOn){
    logDebug "handleDisplayOn()"

    def result = false

    parent.sendBypassRequest(device, [
        data: [ "state": (displayOn == "on")],
        "method": "setDisplay",
        "source": "APP"
    ]) { resp ->
        if (checkHttpResponse("handleDisplayOn", resp))
        {
            logDebug "Set display"
            result = true
        }
    }
    return result
}

def checkHttpResponse(action, resp) {
    if (resp.status == 200 || resp.status == 201 || resp.status == 204)
    return true
    else if (resp.status == 400 || resp.status == 401 || resp.status == 404 || resp.status == 409 || resp.status == 500)
    {
        log.error "${action}: ${resp.status} - ${resp.getData()}"
        return false
    }
    else
    {
        log.error "${action}: unexpected HTTP response: ${resp.status}"
        return false
    }
}
1 Like

Thanks, much appreciated.

Seems like this driver would be able to control other vesync devices (with some new scripts), but since I'm new to this, not sure where to start.

Yes, I think so, at least if they are based on the v2 API. I based my purifier code on this Python repo: https://github.com/webdjoe/pyvesync and then I used Postman to manually validate the APIs.

Let me know if you get more purifiers or humidifiers working, I'd be happy to take a PR.

I just pushed updates to the Levoit drivers onto GitHub. I got myself a 600S, and since I was able to test it, I added support for it.

I also split the drivers up into one file per device type -- the 300S and 400/600S have different speed parameters, and once I figure out how to control the auto mode ('default', 'quiet', 'eco', 'efficient'), there are differences between the 400S and 600S, too. More code to install, but it will allow for more precise control in due time.

Thanks to @elfege for help with the 600S and adding support for 'setLevel()' It didn't occur to me to do that.

1 Like

If I can figure out how to use the Hubitat Package Manager, I will add that in the next update.

Okay, so here's the HPM manifest link:

https://raw.githubusercontent.com/NiklasGustafsson/Hubitat/master/levoitManifest.json

I just made another version of the drivers -- now, it also supports setting the auto-mode preferences (default, eco, efficient, quiet) based on what the device supports. Only the 200S does not support auto-mode, and only the 600S supports the "eco" mode.

So, I've been poking around VesyncIntegration.groovy, I modified the getDevices() method to print out all the devices it sees in my Vesync account. I can see the my In-wall light switches and Outlet Plugs. After creating a child driver based on one of the Levoit, I could get the Hubitat to recognise the device, but I'm having difficulty with the parent.sendBypassRequest command. Not sure what to change the payload command to get my switches to turn off and on.

So, there seems to be two separate, overlapping APIs for VeSync devices -- v1, which login and device enumeration uses, and perhaps some other devices, and v2, which has the word 'bypassV2' in the URL, which is why that method is called what it is.

I relied on this Python repo for inspiration on some of the commands: https://github.com/webdjoe/pyvesync, and then I used Postman to figure things out before actually putting it in code -- after some trial-and-error, I got things to work. That repo doesn't have everything, though. For example, I had to use Charles Proxy (an SSL proxy for iOS) to figure out the payload for setting the purifier auto mode preferences (default, eco, quiet, etc.)